Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

867 lines · 41.8 KBRaw History
1import { execFileSync, spawn } from "node:child_process";
2import { createServer } from "node:http";
3import type { AddressInfo } from "node:net";
4import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
5import path from "node:path";
6import { afterEach, beforeEach, describe, expect, it } from "vitest";
7import type { JobSpec, StepSpec } from "@diffinery/shared";
8import { apiBaseFor, runAgentJob } from "../src/agent.js";
9import type { JobRunner } from "../src/job.js";
10import { makeSpec, startFakeApi, tmp, type FakeApi } from "./helpers.js";
11
12let api: FakeApi;
13let workRoot: string;
14
15beforeEach(async () => {
16 api = await startFakeApi();
17 workRoot = tmp();
18});
19afterEach(async () => {
20 await api.close();
21 rmSync(workRoot, { recursive: true, force: true });
22});
23
24function run(spec: JobSpec, extra: { onRunner?: (r: JobRunner) => void } = {}) {
25 return runAgentJob(spec, {
26 apiBase: apiBaseFor(spec.apiUrl, spec.jobId),
27 workRoot,
28 toolCache: path.join(workRoot, "_tool"),
29 logIntervalMs: 50,
30 echoLogs: false,
31 ...extra,
32 });
33}
34
35const workspace = () => path.join(workRoot, "widgets");
36
37/** Async spawn: the fake API lives in this process, so the event loop must stay free. */
38function spawnAsync(cmd: string, args: string[], env: NodeJS.ProcessEnv): Promise<{ status: number | null; stdout: string; stderr: string }> {
39 return new Promise((resolve) => {
40 const child = spawn(cmd, args, { env });
41 let stdout = "";
42 let stderr = "";
43 child.stdout.on("data", (d) => (stdout += d));
44 child.stderr.on("data", (d) => (stderr += d));
45 const t = setTimeout(() => child.kill("SIGKILL"), 30_000);
46 child.on("close", (status) => {
47 clearTimeout(t);
48 resolve({ status, stdout, stderr });
49 });
50 });
51}
52
53describe("job runner end-to-end", () => {
54 it("propagates env, outputs, path, summaries and job outputs", async () => {
55 const spec = makeSpec(
56 api,
57 [
58 {
59 id: "one",
60 name: "Step ${{ github.job }} one",
61 run: [
62 'echo "hello from step one"',
63 'echo "FOO=bar" >> "$GITHUB_ENV"',
64 '{ echo "MULTI<<EOF"; echo "line1"; echo "line2"; echo "EOF"; } >> "$GITHUB_ENV"',
65 'echo "greeting=hi there" >> "$GITHUB_OUTPUT"',
66 'mkdir -p "$RUNNER_TEMP/mybin" "$GITHUB_WORKSPACE/sub"',
67 `printf '#!/bin/sh\\necho mytool-ran\\n' > "$RUNNER_TEMP/mybin/mytool"`,
68 'chmod +x "$RUNNER_TEMP/mybin/mytool"',
69 'echo "$RUNNER_TEMP/mybin" >> "$GITHUB_PATH"',
70 'echo "::set-output name=legacy::old-style"',
71 'echo "# Summary one" >> "$GITHUB_STEP_SUMMARY"',
72 ].join("\n"),
73 },
74 {
75 id: "two",
76 env: { STEP_VAR: "${{ steps.one.outputs.greeting }}", FOO: "${{ env.FOO }}-override" },
77 run: [
78 'echo "foo=$FOO"',
79 'echo "multi=$(echo "$MULTI" | tr "\\n" ",")"',
80 "mytool",
81 'echo "step_var=$STEP_VAR job_var=$JOB_VAR matrix=${{ matrix.os }}"',
82 'echo "ci=$CI actions=$GITHUB_ACTIONS diffinery=$DIFFINERY repo=$GITHUB_REPOSITORY ref_name=$GITHUB_REF_NAME type=$GITHUB_REF_TYPE"',
83 'echo "server=$GITHUB_SERVER_URL api=$GITHUB_API_URL os=$RUNNER_OS job=$GITHUB_JOB actor=$GITHUB_ACTOR"',
84 'echo "event=$(cat "$GITHUB_EVENT_PATH" | tr -d " \\n")"',
85 'echo "## Summary two" >> "$GITHUB_STEP_SUMMARY"',
86 ].join("\n"),
87 },
88 { name: "conditional", if: "steps.one.outputs.greeting == 'hi there'", run: 'echo "conditional ran with $FOO"' },
89 { name: "never", if: "steps.one.outputs.greeting == 'nope'", run: 'echo "SHOULD NOT RUN"' },
90 { name: "wd", "working-directory": "sub", run: 'echo "pwd=$(pwd)"' },
91 { name: "py", shell: "python", run: 'import os\nprint("py-foo=" + os.environ["FOO"])' },
92 { name: "custom", shell: "sh -e {0}", run: 'echo "custom shell ok"' },
93 ],
94 {
95 env: { JOB_VAR: "${{ github.repository }}-x" },
96 matrix: { os: "linux" },
97 outputs: { greeting: "${{ steps.one.outputs.greeting }}", legacy: "${{ steps.one.outputs.legacy }}", combo: "${{ steps.two.outcome }}/${{ env.FOO }}" },
98 },
99 );
100 const c = await run(spec);
101 const log = api.logText();
102 expect(c.conclusion, log).toBe("success");
103 expect(api.completion).toEqual(c);
104 expect(c.outputs).toEqual({ greeting: "hi there", legacy: "old-style", combo: "success/bar" });
105 expect(c.summary).toContain("# Summary one");
106 expect(c.summary).toContain("## Summary two");
107
108 const s1 = api.logText(1);
109 expect(s1).toContain("foo=bar-override");
110 expect(s1).toContain("multi=line1,line2,");
111 expect(s1).toContain("mytool-ran");
112 expect(s1).toContain("step_var=hi there job_var=acme/widgets-x matrix=linux");
113 expect(s1).toContain("ci=true actions=true diffinery=true repo=acme/widgets ref_name=main type=branch");
114 expect(s1).toContain(`server=${api.url} api=${api.url}/api/v3 os=Linux job=build actor=octo`);
115 expect(s1).toContain('event={"ref":"refs/heads/main","pusher":{"name":"octo"}}');
116 expect(api.logText(2)).toContain("conditional ran with bar");
117 expect(log).not.toContain("SHOULD NOT RUN");
118 expect(api.logText(4)).toContain(`pwd=${path.join(workspace(), "sub")}`);
119 expect(api.logText(5)).toContain("py-foo=bar");
120 expect(api.logText(6)).toContain("custom shell ok");
121
122 expect(api.lastReport(0)).toMatchObject({ status: "completed", conclusion: "success", name: "Step build one" });
123 expect(api.lastReport(3)).toMatchObject({ status: "completed", conclusion: "skipped" });
124 expect(api.steps.find((s) => s.index === 0 && s.report.status === "in_progress")).toBeTruthy();
125 expect(api.steps.find((s) => s.index === 3 && s.report.status === "in_progress")).toBeFalsy();
126 // all requests authenticated with the job token
127 expect(api.requests.every((r) => r.auth === `Bearer ${api.token}`)).toBe(true);
128 });
129
130 it("handles failures, continue-on-error, failure() and always()", async () => {
131 const spec = makeSpec(api, [
132 { id: "ok", run: "echo ok" },
133 { id: "soft", "continue-on-error": true, run: "echo soft-failing; exit 3" },
134 { name: "after-soft", run: 'echo "after soft ${{ steps.soft.outcome }} ${{ steps.soft.conclusion }} ${{ job.status }}"' },
135 { id: "bad", name: "Compile", run: 'echo "compiling..."\necho "::error file=src/a.ts,line=4::type mismatch"\nexit 1' },
136 { name: "skipped-by-failure", run: "echo SHOULD-NOT-RUN" },
137 { name: "on-failure", if: "failure()", run: 'echo "failure handler: ${{ steps.bad.outcome }} ${{ job.status }}"' },
138 { name: "always", if: "${{ always() }}", run: "echo always-ran" },
139 { name: "on-success", if: "success()", run: "echo SHOULD-NOT-RUN-2" },
140 { name: "cancelled-only", if: "cancelled()", run: "echo SHOULD-NOT-RUN-3" },
141 ]);
142 const c = await run(spec);
143 expect(c.conclusion).toBe("failure");
144 expect(c.failure).toEqual({ step: 3, message: "src/a.ts:4: type mismatch" });
145 expect(api.lastReport(1)?.conclusion).toBe("success");
146 expect(api.logText(2)).toContain("after soft failure success success");
147 expect(api.lastReport(3)?.conclusion).toBe("failure");
148 expect(api.lastReport(4)?.conclusion).toBe("skipped");
149 expect(api.logText(5)).toContain("failure handler: failure failure");
150 expect(api.lastReport(5)?.conclusion).toBe("success");
151 expect(api.logText(6)).toContain("always-ran");
152 expect(api.lastReport(7)?.conclusion).toBe("skipped");
153 expect(api.lastReport(8)?.conclusion).toBe("skipped");
154 expect(api.logText()).not.toMatch(/SHOULD-NOT-RUN/);
155 expect(api.logText(3)).toContain("##[error]src/a.ts:4: type mismatch");
156 expect(api.logText(3)).toContain("##[error]Process completed with exit code 1.");
157 });
158
159 it("summarises failures without annotations from the step's last log lines", async () => {
160 const spec = makeSpec(api, [{ run: 'for i in $(seq 1 30); do echo "line $i"; done\necho "fatal: it broke" >&2\nexit 7' }]);
161 const c = await run(spec);
162 expect(c.conclusion).toBe("failure");
163 expect(c.failure?.step).toBe(0);
164 expect(c.failure?.message).toMatch(/^Process completed with exit code 7\./);
165 expect(c.failure?.message).toContain("fatal: it broke");
166 expect(c.failure?.message).toContain("line 30");
167 expect(c.failure?.message).not.toContain("line 5\n");
168 });
169
170 it("masks secrets (incl. multi-line and ::add-mask::) in logs and outputs", async () => {
171 const spec = makeSpec(
172 api,
173 [
174 {
175 id: "s",
176 env: { TOKEN: "${{ secrets.GITHUB_TOKEN }}", PEM: "${{ secrets.PEM }}" },
177 run: [
178 'echo "key=${{ secrets.API_KEY }}"',
179 'echo "token=$TOKEN"',
180 'echo "$PEM"',
181 'V=dynamic; echo "::add-mask::${V}-secret-42"',
182 'echo "now ${V}-secret-42 is hidden"',
183 'echo "leak=${{ secrets.API_KEY }}" >> "$GITHUB_OUTPUT"',
184 'echo "fine=visible" >> "$GITHUB_OUTPUT"',
185 ].join("\n"),
186 },
187 ],
188 {
189 secrets: { API_KEY: "s3cr3t-value", PEM: "-----BEGIN-----\nabcdefghijklmnop\n-----END-----" },
190 outputs: { leak: "${{ steps.s.outputs.leak }}", fine: "${{ steps.s.outputs.fine }}" },
191 },
192 );
193 const c = await run(spec);
194 expect(c.conclusion).toBe("success");
195 const log = api.logText();
196 expect(log).not.toContain("s3cr3t-value");
197 expect(log).not.toContain(api.token);
198 expect(log).not.toContain("abcdefghijklmnop");
199 expect(log).not.toContain("dynamic-secret-42");
200 expect(log).toContain("key=***");
201 expect(log).toContain("token=***");
202 expect(log).toContain("now *** is hidden");
203 expect(c.outputs).toEqual({ fine: "visible" });
204 });
205
206 it("interprets workflow commands", async () => {
207 const spec = makeSpec(
208 api,
209 [
210 {
211 run: [
212 'echo "::group::My Group"',
213 "echo inside",
214 'echo "::endgroup::"',
215 'echo "::warning title=Heads up::be careful"',
216 'echo "::notice::multi%0Aline"',
217 'echo "::debug::hidden debug"',
218 'T=pause123; echo "::stop-commands::$T"',
219 'echo "::warning::not a command"',
220 'echo "::$T::"',
221 'echo "::warning::real again"',
222 'echo "::save-state name=x::y"',
223 ].join("\n"),
224 },
225 ],
226 {},
227 );
228 const c = await run(spec);
229 expect(c.conclusion).toBe("success");
230 const lines = api.logs.filter((l) => l.step === 0).map((l) => l.text);
231 const after = lines.slice(lines.indexOf("##[endgroup]") + 1);
232 expect(after).toEqual([
233 "##[group]My Group",
234 "inside",
235 "##[endgroup]",
236 "##[warning]Heads up: be careful",
237 "##[notice]multi",
238 "line",
239 "::warning::not a command",
240 "##[warning]real again",
241 ]);
242 });
243
244 it("shows ::debug:: when ACTIONS_STEP_DEBUG is set", async () => {
245 const spec = makeSpec(api, [{ run: 'echo "::debug::now visible"; echo "dbg=$RUNNER_DEBUG"' }], { secrets: { ACTIONS_STEP_DEBUG: "true" } });
246 await run(spec);
247 expect(api.logText(0)).toContain("##[debug]now visible");
248 expect(api.logText(0)).toContain("dbg=1");
249 });
250
251 it("runs composite and node local actions with pre/post and state", async () => {
252 const ws = workspace();
253 mkdirSync(path.join(ws, ".github/actions/greet"), { recursive: true });
254 mkdirSync(path.join(ws, ".github/actions/nodey"), { recursive: true });
255 writeFileSync(
256 path.join(ws, ".github/actions/greet/action.yml"),
257 `name: Greet
258description: composite greet
259inputs:
260 who:
261 description: who
262 required: true
263 punct:
264 default: "!"
265outputs:
266 message:
267 description: msg
268 value: \${{ steps.say.outputs.msg }}
269runs:
270 using: composite
271 steps:
272 - id: say
273 shell: bash
274 run: |
275 echo "Hello \${{ inputs.who }}\${{ inputs.punct }}"
276 echo "msg=Hello \${{ inputs.who }}" >> "$GITHUB_OUTPUT"
277 echo "action path is $GITHUB_ACTION_PATH"
278 echo "inherited=$OUTER"
279 - name: nested if
280 if: \${{ inputs.who == 'nobody' }}
281 shell: bash
282 run: echo "SHOULD-NOT-RUN"
283 - shell: bash
284 run: echo "COMPOSITE_ENV=set" >> $GITHUB_ENV
285 - uses: ./.github/actions/nodey
286 with:
287 my-input: from-composite
288`,
289 );
290 writeFileSync(
291 path.join(ws, ".github/actions/nodey/action.yml"),
292 `name: nodey
293inputs:
294 my-input:
295 default: default-val
296 repo:
297 default: \${{ github.repository }}
298outputs:
299 result:
300 description: r
301runs:
302 using: node20
303 pre: pre.js
304 main: index.js
305 post: post.js
306 post-if: success()
307`,
308 );
309 writeFileSync(
310 path.join(ws, ".github/actions/nodey/pre.js"),
311 `require("fs").appendFileSync(process.env.GITHUB_STATE, "fromPre=pre-state\\n"); console.log("pre ran");`,
312 );
313 writeFileSync(
314 path.join(ws, ".github/actions/nodey/index.js"),
315 `const fs = require("fs");
316console.log("input=" + process.env["INPUT_MY-INPUT"] + " repo=" + process.env.INPUT_REPO + " prestate=" + process.env.STATE_fromPre);
317fs.appendFileSync(process.env.GITHUB_OUTPUT, "result=from-node-" + process.env["INPUT_MY-INPUT"] + "\\n");
318fs.appendFileSync(process.env.GITHUB_STATE, "saved=state-" + process.env["INPUT_MY-INPUT"] + "\\n");
319`,
320 );
321 writeFileSync(path.join(ws, ".github/actions/nodey/post.js"), `console.log("post ran with state " + process.env.STATE_saved + "/" + process.env.STATE_fromPre);`);
322
323 const spec = makeSpec(api, [
324 { uses: "./.github/actions/greet", id: "greet", with: { who: "World" }, env: { OUTER: "outer-env" } },
325 { run: 'echo "composite said ${{ steps.greet.outputs.message }} env=$COMPOSITE_ENV"' },
326 { uses: "./.github/actions/nodey", id: "nodey", with: { "my-input": "hello" } },
327 { run: 'echo "node said ${{ steps.nodey.outputs.result }}"' },
328 ]);
329 const c = await run(spec);
330 const log = api.logText();
331 expect(c.conclusion, log).toBe("success");
332 const s0 = api.logText(0);
333 expect(s0).toContain("Hello World!");
334 expect(s0).toContain(`action path is ${path.join(ws, ".github/actions/greet")}`);
335 expect(s0).toContain("inherited=outer-env");
336 expect(s0).toContain("input=from-composite repo=acme/widgets prestate=pre-state");
337 expect(log).not.toContain("SHOULD-NOT-RUN");
338 expect(api.logText(1)).toContain("composite said Hello World env=set");
339 expect(api.logText(2)).toContain("pre ran");
340 expect(api.logText(2)).toContain("input=hello repo=acme/widgets prestate=pre-state");
341 expect(api.logText(3)).toContain("node said from-node-hello");
342 // post steps run in reverse order after the main steps
343 expect(api.logText(4)).toContain("post ran with state state-hello/pre-state");
344 expect(api.logText(5)).toContain("post ran with state state-from-composite/pre-state");
345 expect(api.lastReport(4)).toMatchObject({ status: "completed", conclusion: "success", name: "Post Run ./.github/actions/nodey" });
346 });
347
348 it("fails composite steps and reports the inner error", async () => {
349 const ws = workspace();
350 mkdirSync(path.join(ws, "act"), { recursive: true });
351 writeFileSync(
352 path.join(ws, "act/action.yaml"),
353 `runs:
354 using: composite
355 steps:
356 - run: echo "::error::inner broke"; exit 2
357 shell: bash
358 - run: echo "inner-cleanup"
359 if: always()
360 shell: bash
361`,
362 );
363 const c = await run(makeSpec(api, [{ uses: "./act" }, { run: "echo after", if: "always()" }]));
364 expect(c.conclusion).toBe("failure");
365 expect(c.failure).toEqual({ step: 0, message: "inner broke" });
366 expect(api.logText(0)).toContain("inner-cleanup");
367 expect(api.logText(1)).toContain("after");
368 });
369
370 it("downloads and runs remote actions (owner/repo/path@ref)", async () => {
371 // fake codeload: serves a tarball with a top-level folder, like GitHub does
372 const src = tmp("remote-action-");
373 const top = path.join(src, "tools-abc123");
374 mkdirSync(path.join(top, "hello"), { recursive: true });
375 writeFileSync(
376 path.join(top, "hello/action.yml"),
377 "name: hello\ninputs:\n name:\n default: anon\noutputs:\n said:\n description: x\nruns:\n using: 'node24'\n main: 'main.js'\n",
378 );
379 writeFileSync(
380 path.join(top, "hello/main.js"),
381 'require("fs").appendFileSync(process.env.GITHUB_OUTPUT, `said=hi ${process.env.INPUT_NAME}\\n`); console.log("remote action ran in", process.env.GITHUB_ACTION_REPOSITORY, process.env.GITHUB_ACTION_REF);',
382 );
383 const tarball = path.join(src, "a.tgz");
384 execFileSync("tar", ["-czf", tarball, "-C", src, "tools-abc123"]);
385 const hits: string[] = [];
386 const server = createServer((req, res) => {
387 hits.push(req.url ?? "");
388 if (req.url === "/acme/tools/tar.gz/v1") {
389 res.writeHead(200, { "content-type": "application/gzip" });
390 res.end(readFileSync(tarball));
391 } else {
392 res.writeHead(404);
393 res.end();
394 }
395 });
396 await new Promise<void>((r) => server.listen(0, "127.0.0.1", () => r()));
397 const prev = process.env.DIFFINERY_ACTIONS_DOWNLOAD_URL;
398 process.env.DIFFINERY_ACTIONS_DOWNLOAD_URL = `http://127.0.0.1:${(server.address() as AddressInfo).port}/{owner}/{repo}/tar.gz/{ref}`;
399 try {
400 const c = await run(
401 makeSpec(api, [
402 { id: "h", uses: "acme/tools/hello@v1", with: { name: "bob" } },
403 { uses: "acme/tools/hello@v1" },
404 { run: 'echo "out=${{ steps.h.outputs.said }}"' },
405 { uses: "acme/missing@v9" },
406 ]),
407 );
408 expect(api.logText(0)).toContain("remote action ran in acme/tools v1");
409 expect(api.logText(2)).toContain("out=hi bob");
410 expect(hits.filter((h) => h === "/acme/tools/tar.gz/v1")).toHaveLength(1); // cached after first download
411 expect(c.conclusion).toBe("failure");
412 expect(c.failure?.step).toBe(3);
413 expect(c.failure?.message).toContain("Unable to resolve action 'acme/missing@v9'");
414 } finally {
415 if (prev === undefined) delete process.env.DIFFINERY_ACTIONS_DOWNLOAD_URL;
416 else process.env.DIFFINERY_ACTIONS_DOWNLOAD_URL = prev;
417 await new Promise<void>((r) => server.close(() => r()));
418 rmSync(src, { recursive: true, force: true });
419 }
420 });
421
422 it("runs github-script, and built-ins it cannot mirror, as the real actions", async () => {
423 // fake codeload serving one node action for every repository
424 const src = tmp("real-action-");
425 mkdirSync(path.join(src, "top"), { recursive: true });
426 writeFileSync(path.join(src, "top/action.yml"), "name: real\nruns:\n using: node20\n main: main.js\n");
427 writeFileSync(
428 path.join(src, "top/main.js"),
429 'const e = process.env; console.log(`real ${e.GITHUB_ACTION_REPOSITORY}@${e.GITHUB_ACTION_REF} server=${e.GITHUB_SERVER_URL} results=${e.ACTIONS_RESULTS_URL} v2=${e.ACTIONS_CACHE_SERVICE_V2} token=${e.ACTIONS_RUNTIME_TOKEN ? "set" : "unset"} image=${e.ImageOS}`); require("fs").appendFileSync(e.GITHUB_OUTPUT, "artifact-url=https://github.com/acme/widgets/actions/runs/1/artifacts/7\\n");',
430 );
431 const tarball = path.join(src, "a.tgz");
432 execFileSync("tar", ["-czf", tarball, "-C", src, "top"]);
433 const hits: string[] = [];
434 const server = createServer((req, res) => {
435 hits.push(req.url ?? "");
436 res.writeHead(200, { "content-type": "application/gzip" });
437 res.end(readFileSync(tarball));
438 });
439 await new Promise<void>((r) => server.listen(0, "127.0.0.1", () => r()));
440 const prev = process.env.DIFFINERY_ACTIONS_DOWNLOAD_URL;
441 process.env.DIFFINERY_ACTIONS_DOWNLOAD_URL = `http://127.0.0.1:${(server.address() as AddressInfo).port}/{owner}/{repo}/{ref}`;
442 try {
443 const c = await run(
444 makeSpec(api, [
445 { uses: "actions/github-script@v7", with: { script: "return 1" } },
446 { uses: "actions/cache@v4", env: { DIFFINERY_BUILTINS: "false" }, with: { path: "x", key: "k" } },
447 { uses: "actions/setup-node@v4", with: { "node-version": "20", "some-future-input": "x" } },
448 { uses: "actions/checkout@main" },
449 { id: "up", uses: "actions/upload-artifact@v4", with: { name: "a", path: "x", "unknown-input": "1" } },
450 { run: 'echo "url=${{ steps.up.outputs.artifact-url }}"' },
451 ]),
452 );
453 expect(c.conclusion, api.logText()).toBe("success");
454 expect(api.logText(0)).toContain(`real actions/github-script@v7 server=${api.url} results=${api.url}/ v2=true token=set image=ubuntu24`);
455 expect(api.logText(0)).not.toContain(api.runtimeToken);
456 expect(api.logText(1)).toContain("real actions/cache@v4");
457 expect(api.logText(2)).toContain("real actions/setup-node@v4");
458 expect(api.logText(3)).toContain("real actions/checkout@main");
459 // @actions/artifact refuses GHES, so the artifact actions see github.com and their URLs are mapped back
460 expect(api.logText(4)).toContain("real actions/upload-artifact@v4 server=https://github.com");
461 expect(api.logText(5)).toContain(`url=${api.url}/acme/widgets/actions/runs/1/artifacts/7`);
462 expect(hits).toEqual(["/actions/github-script/v7", "/actions/cache/v4", "/actions/setup-node/v4", "/actions/checkout/main", "/actions/upload-artifact/v4"]);
463 } finally {
464 if (prev === undefined) delete process.env.DIFFINERY_ACTIONS_DOWNLOAD_URL;
465 else process.env.DIFFINERY_ACTIONS_DOWNLOAD_URL = prev;
466 await new Promise<void>((r) => server.close(() => r()));
467 rmSync(src, { recursive: true, force: true });
468 }
469 });
470
471 it("enforces step timeout-minutes", async () => {
472 const start = Date.now();
473 const c = await run(makeSpec(api, [{ "timeout-minutes": 0.02, run: "sleep 30" }, { if: "always()", run: "echo after-timeout" }]));
474 expect(Date.now() - start).toBeLessThan(15_000);
475 expect(c.conclusion).toBe("failure");
476 expect(c.failure?.message).toContain("timed out");
477 expect(api.logText(1)).toContain("after-timeout");
478 });
479
480 it("cancels on request: kills the process group and reports cancelled", async () => {
481 let runner: JobRunner | undefined;
482 const spec = makeSpec(api, [{ run: "echo started; (sleep 60 & wait)" }, { run: "echo never" }, { if: "always()", run: "echo never2" }]);
483 const p = run(spec, { onRunner: (r) => (runner = r) });
484 const t0 = Date.now();
485 while (!api.logText(0).includes("started") && Date.now() - t0 < 10_000) await new Promise((r) => setTimeout(r, 50));
486 runner!.cancel();
487 const c = await p;
488 expect(c.conclusion).toBe("cancelled");
489 expect(api.lastReport(0)?.conclusion).toBe("cancelled");
490 expect(api.lastReport(1)?.conclusion).toBe("cancelled");
491 expect(api.lastReport(2)?.conclusion).toBe("cancelled");
492 expect(api.completion?.conclusion).toBe("cancelled");
493 expect(Date.now() - t0).toBeLessThan(10_000);
494 });
495
496 it("never loses log lines when the log endpoint flakes", async () => {
497 api.failLogs = 2;
498 const c = await run(makeSpec(api, [{ run: "for i in $(seq 1 500); do echo \"n$i\"; done" }]));
499 expect(c.conclusion).toBe("success");
500 const nums = api.logs.filter((l) => l.step === 0 && /^n\d+$/.test(l.text)).map((l) => l.text);
501 expect(nums).toEqual(Array.from({ length: 500 }, (_, i) => `n${i + 1}`));
502 });
503
504 it("caches and restores via the cache service, and round-trips artifacts", async () => {
505 const steps: StepSpec[] = [
506 { run: 'mkdir -p deps dist/sub && echo "${{ github.run_id }}" > deps/marker && echo a > dist/a.txt && echo b > dist/sub/b.txt' },
507 { id: "cache", uses: "actions/cache@v4", with: { path: "deps", key: "deps-${{ hashFiles('**/lock.txt') }}-v1", "restore-keys": "deps-" } },
508 { run: 'echo "hit=${{ steps.cache.outputs.cache-hit }}"; cat deps/marker' },
509 { uses: "actions/upload-artifact@v4", with: { name: "dist", path: "dist/" } },
510 { id: "dl", uses: "actions/download-artifact@v4", with: { name: "dist", path: "out" } },
511 { run: 'cat out/a.txt out/sub/b.txt; echo "dl=${{ steps.dl.outputs.download-path }}"' },
512 ];
513 mkdirSync(workspace(), { recursive: true });
514 writeFileSync(path.join(workspace(), "lock.txt"), "v1");
515 const c1 = await run(makeSpec(api, steps, { runId: "first" }));
516 expect(c1.conclusion, api.logText()).toBe("success");
517 expect(api.logText(2)).toMatch(/\nhit=\n/); // like actions/cache, a miss leaves cache-hit unset
518 expect([...api.cache.keys()]).toHaveLength(1);
519 const key = [...api.cache.keys()][0]!;
520 expect(key).toMatch(/^deps-[0-9a-f]{64}-v1$/);
521 expect(api.artifacts.get("dist")?.mimeType).toBe("application/zip");
522 expect(api.artifacts.get("dist")?.hash).toMatch(/^sha256:[0-9a-f]{64}$/);
523 expect(api.logText(5)).toMatch(/a\nb\ndl=.*\/out/);
524 // post step saved the cache
525 expect(api.steps.some((s) => s.report.name === "Post Run actions/cache@v4" && s.report.conclusion === "success")).toBe(true);
526
527 // second run: wipe the cached dir, it must come back from the cache
528 rmSync(path.join(workspace(), "deps"), { recursive: true, force: true });
529 api.logs = [];
530 api.steps = [];
531 const steps2 = [steps[1]!, steps[2]!];
532 const c2 = await run(makeSpec(api, steps2, { runId: "second" }));
533 expect(c2.conclusion, api.logText()).toBe("success");
534 expect(api.logText(1)).toContain("hit=true");
535 expect(api.logText(1)).toContain("first");
536 // like actions/cache, the post step runs but does not save over an exact hit
537 expect(api.logText()).toContain(`Cache hit occurred on the primary key ${key}, not saving cache.`);
538 expect([...api.cache.keys()]).toEqual([key]);
539 });
540
541 it("checks out a repository with actions/checkout", async () => {
542 const origin = tmp("origin-");
543 const git = (...args: string[]) => execFileSync("git", args, { cwd: origin, env: { ...process.env, GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@t", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@t" } }).toString().trim();
544 git("init", "-q", "-b", "main");
545 writeFileSync(path.join(origin, "README.md"), "first\n");
546 git("add", ".");
547 git("commit", "-q", "-m", "first");
548 writeFileSync(path.join(origin, "README.md"), "second\n");
549 git("commit", "-q", "-am", "second");
550 const sha = git("rev-parse", "HEAD");
551 git("tag", "v1");
552 const spec = makeSpec(
553 api,
554 [
555 { id: "co", uses: "actions/checkout@v4" },
556 { run: 'cat README.md; git rev-parse HEAD; git rev-parse --abbrev-ref HEAD; echo "commit=${{ steps.co.outputs.commit }}"; git rev-list --count HEAD' },
557 { uses: "actions/checkout@v4", with: { path: "full", "fetch-depth": "0", ref: "v1" } },
558 { run: "git -C full rev-list --count HEAD; cat full/README.md" },
559 ],
560 { sha, repo: { owner: "acme", name: "widgets", cloneUrl: `file://${origin}`, defaultBranch: "main" } },
561 );
562 const c = await run(spec);
563 expect(c.conclusion, api.logText()).toBe("success");
564 const out = api.logText(1);
565 expect(out).toContain("second");
566 expect(out).toContain(sha);
567 expect(out).toMatch(/\nmain\n/);
568 expect(out).toContain(`commit=${sha}`);
569 expect(out).toMatch(/\n1$/);
570 expect(api.logText(3)).toMatch(/2\nsecond/);
571 expect(existsSync(path.join(workspace(), ".git"))).toBe(true);
572 rmSync(origin, { recursive: true, force: true });
573 });
574
575 it("checks out with LFS, submodules and sparse-checkout", async () => {
576 const root = tmp("checkout-features-");
577 const gitIn = (cwd: string, ...args: string[]) =>
578 execFileSync("git", ["-c", "protocol.file.allow=always", ...args], { cwd, env: { ...process.env, GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@t", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@t" } }).toString().trim();
579 const sub = path.join(root, "sub");
580 const origin = path.join(root, "origin");
581 mkdirSync(sub);
582 gitIn(sub, "init", "-q", "-b", "main");
583 writeFileSync(path.join(sub, "lib.txt"), "from submodule\n");
584 gitIn(sub, "add", ".");
585 gitIn(sub, "commit", "-q", "-m", "sub");
586 mkdirSync(path.join(origin, "a"), { recursive: true });
587 mkdirSync(path.join(origin, "b"));
588 gitIn(origin, "init", "-q", "-b", "main");
589 gitIn(origin, "lfs", "install", "--local");
590 gitIn(origin, "lfs", "track", "*.bin");
591 writeFileSync(path.join(origin, "a/x.txt"), "x\n");
592 writeFileSync(path.join(origin, "b/y.txt"), "y\n");
593 writeFileSync(path.join(origin, "top.txt"), "top\n");
594 writeFileSync(path.join(origin, "big.bin"), "real lfs content\n");
595 gitIn(origin, "submodule", "add", "-q", `file://${sub}`, "vendor/sub");
596 gitIn(origin, "add", ".");
597 gitIn(origin, "commit", "-q", "-m", "init");
598 const sha = gitIn(origin, "rev-parse", "HEAD");
599 const spec = makeSpec(
600 api,
601 [
602 { uses: "actions/checkout@v4", with: { path: "full", lfs: "true", submodules: "recursive", "persist-credentials": "false" } },
603 { run: "cat full/big.bin full/vendor/sub/lib.txt full/b/y.txt" },
604 { uses: "actions/checkout@v4", with: { path: "plain" } },
605 { run: "head -n 1 plain/big.bin; ls plain/vendor/sub | wc -l" },
606 { uses: "actions/checkout@v4", with: { path: "sparse", "sparse-checkout": "a" } },
607 { run: "ls sparse; ls sparse/a" },
608 { uses: "actions/checkout@v4", with: { path: "nocone", "sparse-checkout": "b/y.txt", "sparse-checkout-cone-mode": "false" } },
609 { run: "find nocone -type f -not -path '*/.git/*' | sort" },
610 ],
611 {
612 sha,
613 repo: { owner: "acme", name: "widgets", cloneUrl: `file://${origin}`, defaultBranch: "main" },
614 env: { GIT_CONFIG_COUNT: "1", GIT_CONFIG_KEY_0: "protocol.file.allow", GIT_CONFIG_VALUE_0: "always" },
615 },
616 );
617 try {
618 const c = await run(spec);
619 expect(c.conclusion, api.logText()).toBe("success");
620 expect(api.logText(1)).toContain("real lfs content\nfrom submodule\ny");
621 expect(api.logText(3)).toContain("version https://git-lfs.github.com/spec/v1");
622 expect(api.logText(3)).toMatch(/\n0$/);
623 expect(api.logText(5)).toMatch(/\na\nbig\.bin\ntop\.txt\nx\.txt$/); // cone mode keeps root files, drops b/ and vendor/
624 expect(api.logText(7)).toMatch(/\nnocone\/b\/y\.txt$/);
625 } finally {
626 rmSync(root, { recursive: true, force: true });
627 }
628 });
629
630 it("keeps the job token out of .git/config unless persist-credentials, even when checkout fails", async () => {
631 const root = tmp("http-git-");
632 const origin = path.join(root, "acme/widgets.git");
633 mkdirSync(origin, { recursive: true });
634 const seed = path.join(root, "seed");
635 const g = (cwd: string, ...args: string[]) => execFileSync("git", args, { cwd, env: { ...process.env, GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@t", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@t" } }).toString().trim();
636 g(origin, "init", "-q", "--bare", "-b", "main");
637 mkdirSync(seed);
638 g(seed, "init", "-q", "-b", "main");
639 writeFileSync(path.join(seed, "README.md"), "hello\n");
640 g(seed, "add", ".");
641 g(seed, "commit", "-q", "-m", "first");
642 g(seed, "push", "-q", origin, "main");
643 const sha = g(seed, "rev-parse", "HEAD");
644 // git's smart HTTP through `git http-backend`, recording the credentials it is sent
645 const auths: string[] = [];
646 const server = createServer((req, res) => {
647 auths.push(req.headers.authorization ?? "");
648 const u = new URL(req.url ?? "/", "http://x");
649 const cgi = spawn("git", ["http-backend"], {
650 env: { ...process.env, GIT_PROJECT_ROOT: root, GIT_HTTP_EXPORT_ALL: "1", PATH_INFO: u.pathname, QUERY_STRING: u.search.slice(1), REQUEST_METHOD: req.method ?? "GET", CONTENT_TYPE: req.headers["content-type"] ?? "", HTTP_GIT_PROTOCOL: String(req.headers["git-protocol"] ?? "") },
651 });
652 req.pipe(cgi.stdin);
653 let buf = Buffer.alloc(0);
654 cgi.stdout.on("data", (d: Buffer) => (buf = Buffer.concat([buf, d])));
655 cgi.on("close", () => {
656 const sep = buf.indexOf("\r\n\r\n");
657 const head = buf.subarray(0, sep).toString();
658 const headers: Record<string, string> = {};
659 let status = 200;
660 for (const line of head.split("\r\n")) {
661 const [k, ...v] = line.split(":");
662 if (k!.toLowerCase() === "status") status = Number(v.join(":").trim().split(" ")[0]);
663 else headers[k!] = v.join(":").trim();
664 }
665 res.writeHead(status, headers);
666 res.end(buf.subarray(sep + 4));
667 });
668 });
669 await new Promise<void>((r) => server.listen(0, "127.0.0.1", () => r()));
670 const url = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
671 try {
672 const headers = "git config --get-regexp 'http\\..*\\.extraheader' | wc -l";
673 const spec = makeSpec(
674 api,
675 [
676 { uses: "actions/checkout@v4", with: { path: "a", "persist-credentials": "false" } },
677 { run: `echo "a=$(cd a && ${headers})"; cat a/README.md` },
678 { uses: "actions/checkout@v4", with: { path: "b" } },
679 { run: `echo "b=$(cd b && ${headers})"` },
680 { uses: "actions/checkout@v4", "continue-on-error": true, with: { path: "c", ref: "no-such-branch" } },
681 { run: `echo "c=$(cd c && ${headers})"` },
682 ],
683 { sha, repo: { owner: "acme", name: "widgets", cloneUrl: `${url}/acme/widgets.git`, defaultBranch: "main" } },
684 );
685 const c = await run(spec);
686 expect(c.conclusion, api.logText()).toBe("success");
687 // the token reached the server while fetching...
688 expect(auths.filter((a) => a.toLowerCase().startsWith("basic ")).length).toBeGreaterThan(0);
689 expect(api.logText(1)).toContain("hello");
690 // ...but stays in .git/config only when persisted, and not after a failed checkout
691 expect(api.logText(1)).toContain("a=0");
692 expect(api.logText(3)).toContain("b=1");
693 expect(api.logText(4)).toContain("Failed to fetch ref 'no-such-branch'");
694 expect(api.logText(5)).toContain("c=0");
695 } finally {
696 await new Promise<void>((r) => server.close(() => r()));
697 rmSync(root, { recursive: true, force: true });
698 }
699 });
700
701 it("gives the runtime services' variables to actions, not to run steps, and never the runner's own token", async () => {
702 const c = await runAgentJob(makeSpec(api, [{ run: 'echo "rt=${ACTIONS_RUNTIME_TOKEN:-unset} results=${ACTIONS_RESULTS_URL:-unset} gh=${DIFFINERY_GITHUB_TOKEN:-unset} job=${DIFFINERY_JOB_TOKEN:-unset}"' }]), {
703 apiBase: apiBaseFor(api.url, "job-1"),
704 workRoot,
705 toolCache: path.join(workRoot, "_tool"),
706 logIntervalMs: 50,
707 echoLogs: false,
708 baseEnv: { ...process.env, DIFFINERY_GITHUB_TOKEN: "ghp_operator", DIFFINERY_JOB_TOKEN: "djt_x" },
709 });
710 expect(c.conclusion, api.logText()).toBe("success");
711 expect(api.logText(0)).toContain("rt=unset results=unset gh=unset job=unset");
712 });
713
714 it("sets OIDC request variables when id-token is granted", async () => {
715 const c = await run(
716 makeSpec(api, [{ run: 'curl -s -H "Authorization: Bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=sts" ; echo' }], { idToken: true }),
717 );
718 expect(c.conclusion).toBe("success");
719 expect(api.logText(0)).toContain('{"value":"oidc-for-sts"}');
720 });
721
722 it("evaluates job-level env and spec-level defaults.run", async () => {
723 mkdirSync(path.join(workspace(), "pkg"), { recursive: true });
724 const c = await run(
725 makeSpec(api, [{ run: 'echo "wd=$(basename "$(pwd)") shell=$0 e=$E"' }], {
726 defaults: { run: { shell: "sh -e {0}", "working-directory": "pkg" } },
727 env: { E: "${{ vars.V }}-${{ inputs.i }}-${{ needs.prev.outputs.o }}" },
728 vars: { V: "var" },
729 inputs: { i: "inp" },
730 needs: { prev: { result: "success", outputs: { o: "out" } } },
731 }),
732 );
733 expect(c.conclusion).toBe("success");
734 expect(api.logText(0)).toMatch(/wd=pkg shell=\S+\.sh e=var-inp-out/);
735 });
736
737 it("lets workflow env override CI and DIFFINERY but not GITHUB_* or RUNNER_*", async () => {
738 const c = await run(
739 makeSpec(api, [{ env: { CI: "step" }, run: 'echo "d=$DIFFINERY ci=$CI sha=$GITHUB_SHA os=$RUNNER_OS"' }], {
740 env: { DIFFINERY: "https://diffinery.example", GITHUB_SHA: "nope", RUNNER_OS: "nope" },
741 }),
742 );
743 expect(c.conclusion).toBe("success");
744 expect(api.logText(0)).toMatch(/d=https:\/\/diffinery\.example ci=step sha=[0-9a-f]{40} os=Linux/);
745 });
746});
747
748describe("agent binary (esbuild bundle)", () => {
749 it("runs a --spec job and an env-driven job", async () => {
750 const { build } = await import("esbuild");
751 const out = path.join(workRoot, "agent.mjs");
752 await build({
753 entryPoints: [path.join(__dirname, "../src/main.ts")],
754 bundle: true,
755 platform: "node",
756 target: "node22",
757 format: "esm",
758 outfile: out,
759 logLevel: "silent",
760 // keep in sync with the `build` script in package.json
761 banner: { js: "import { createRequire as __diffineryCreateRequire } from 'node:module'; const require = __diffineryCreateRequire(import.meta.url);" },
762 });
763 const spec = makeSpec(api, [{ run: 'echo "from bundle $GITHUB_JOB"' }], { outputs: { x: "y" } });
764 const specFile = path.join(workRoot, "spec.json");
765 writeFileSync(specFile, JSON.stringify(spec));
766 const env: NodeJS.ProcessEnv = { ...process.env, DIFFINERY_WORK_ROOT: path.join(workRoot, "w1") };
767 delete env.DIFFINERY_API_URL;
768 const r = await spawnAsync(process.execPath, [out, "--spec", specFile], env);
769 expect(r.status, r.stderr + r.stdout).toBe(0);
770 expect(api.completion).toMatchObject({ conclusion: "success", outputs: { x: "y" } });
771 expect(api.logText(0)).toContain("from bundle build");
772
773 // env mode: fetch the spec from the API, saying where the runner runs (its image and container)
774 api.completion = null;
775 api.specs.set("job-2", { ...spec, jobId: "job-2", steps: [{ run: "exit 4" }] });
776 writeFileSync(path.join(workRoot, "image-tag"), "diffinery-runner:abc123\n");
777 const r2 = await spawnAsync(process.execPath, [out], {
778 ...process.env,
779 CLOUDFLARE_LOCATION: "fra06",
780 CLOUDFLARE_REGION: "EEUR",
781 CLOUDFLARE_DURABLE_OBJECT_ID: "d0",
782 DIFFINERY_WORK_ROOT: path.join(workRoot, "w2"),
783 DIFFINERY_API_URL: api.url,
784 DIFFINERY_JOB_ID: "job-2",
785 DIFFINERY_JOB_TOKEN: api.token,
786 });
787 expect(r2.status).toBe(1);
788 const runner = JSON.parse(api.requests.find((q) => q.path === "/runner/jobs/job-2")?.runner ?? "{}");
789 expect(runner).toMatchObject({ image: "diffinery-runner:abc123", location: "fra06, EEUR", instance: "d0" });
790 expect(api.logText()).toContain("Runner: image diffinery-runner:abc123, in fra06, EEUR, container d0");
791 // A hosted runner also names its host's processor and, at the end, the share of its vCPUs' time the host took.
792 if (existsSync("/proc/cpuinfo")) {
793 expect(api.logText()).toContain(`Host: ${runner.cpu}`);
794 expect(api.logText()).toMatch(/Host: \d+% of the vCPUs' time went to other work on the host \(steal\)/);
795 }
796 expect(api.completion).toMatchObject({ conclusion: "failure", failure: { step: 0 } });
797 expect(readFileSync(path.join(workRoot, "w2/_temp/_github_workflow/event.json"), "utf8")).toContain("pusher");
798
799 const startJob = (id: string, steps: StepSpec[]) => {
800 api.completion = null;
801 api.steps = [];
802 api.logs = [];
803 api.specs.set(id, { ...spec, jobId: id, steps });
804 const child = spawn(process.execPath, [out], {
805 env: { ...process.env, DIFFINERY_WORK_ROOT: path.join(workRoot, id), DIFFINERY_API_URL: api.url, DIFFINERY_JOB_ID: id, DIFFINERY_JOB_TOKEN: api.token },
806 });
807 const exited = new Promise<number | null>((r) => child.on("close", (code) => r(code)));
808 return { child, exited };
809 };
810 const waitForLog = async (text: string) => {
811 const t0 = Date.now();
812 while (!api.logs.some((l) => l.text === text) && Date.now() - t0 < 15_000) await new Promise((r) => setTimeout(r, 50));
813 };
814
815 // One SIGTERM (a container rollout): the job keeps running and finishes.
816 const drain = startJob("job-3", [{ run: "echo started; sleep 2" }, { run: "echo after" }]);
817 await waitForLog("started");
818 drain.child.kill("SIGTERM");
819 expect(await drain.exited).toBe(0);
820 expect(api.completion).toMatchObject({ conclusion: "success" });
821 expect(api.completion).not.toHaveProperty("replaced");
822 expect(api.logText()).toContain("being replaced by a new version");
823 expect(api.logText(1)).toContain("after");
824
825 // A second SIGTERM: kill the running step's process group and report cancelled.
826 const t0 = Date.now();
827 const stop = startJob("job-4", [{ run: "echo started; sleep 60" }, { run: "echo never" }]);
828 await waitForLog("started");
829 stop.child.kill("SIGTERM");
830 await new Promise((r) => setTimeout(r, 200));
831 stop.child.kill("SIGTERM");
832 expect(await stop.exited).toBe(1);
833 expect(Date.now() - t0).toBeLessThan(15_000);
834 expect(api.completion).toMatchObject({ conclusion: "cancelled", replaced: true });
835 expect(api.lastReport(0)?.conclusion).toBe("cancelled");
836 expect(api.lastReport(1)?.conclusion).toBe("cancelled");
837
838 // SIGINT (Diffinery cancelling the job): cancel at once, with no drain.
839 const t1 = Date.now();
840 const cancel = startJob("job-5", [{ run: "echo started; sleep 60" }]);
841 await waitForLog("started");
842 cancel.child.kill("SIGINT");
843 expect(await cancel.exited).toBe(1);
844 expect(Date.now() - t1).toBeLessThan(15_000);
845 expect(api.completion).toMatchObject({ conclusion: "cancelled" });
846 expect(api.completion).not.toHaveProperty("replaced");
847 expect(api.logText()).not.toContain("being replaced");
848
849 // A rollout's SIGTERM reaches every process in the container, steps included: the
850 // step fails with a message that names the rollout, the rest of the job is cancelled
851 // without waiting out the drain, and the result says the runner was replaced, so
852 // Diffinery can start the job again on a new runner.
853 const t2 = Date.now();
854 const rollout = startJob("job-6", [{ run: "echo started $$; sleep 60" }, { run: "echo never" }]);
855 while (!/started \d+/.test(api.logText()) && Date.now() - t2 < 15_000) await new Promise((r) => setTimeout(r, 50));
856 const pid = Number(/started (\d+)/.exec(api.logText())?.[1]);
857 rollout.child.kill("SIGTERM");
858 await new Promise((r) => setTimeout(r, 200));
859 process.kill(-pid, "SIGTERM");
860 expect(await rollout.exited).toBe(1);
861 expect(Date.now() - t2).toBeLessThan(15_000);
862 expect(api.completion).toMatchObject({ conclusion: "cancelled", replaced: true });
863 expect(api.lastReport(0)?.conclusion).toBe("cancelled");
864 expect(api.lastReport(1)?.conclusion).toBe("cancelled");
865 expect(api.logText()).toContain("The platform stopped this step's processes while replacing the runner");
866 }, 60_000);
867});