Public: anyone can read this; the crew can change it.
A builder's clone has its dependencies installed before the run starts, so the type check always has its types #6597
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6572-a-builder-s-clone-has-its-dependencies-i → main 4 commits fork 3 threads · 3 unresolved
7 files
changed +328
−4
README.md +12
| @@ -64,6 +64,18 @@ days. A ratio over nothing is `null`, and an agent with no pull has `all` and | ||
| 64 | 64 | `recent` null. Any token of a forge admin may read it (401 with none, 403 for |
| 65 | 65 | anyone else); the tool is listed for admins only. |
| 66 | 66 | |
| 67 | A crew clone's dependencies are installed before the agent starts | |
| 68 | (`installDependencies` in `src/crew/workdir.ts`), once per lockfile hash, so | |
| 69 | the type check has its types. That install runs on the forge side, outside the | |
| 70 | agent's shell sandbox, because the sandbox is built around the agent and the | |
| 71 | install has to finish before it starts. It is bounded instead: a child env of | |
| 72 | `PATH`, `HOME`, `TMPDIR` and the repository's install cache only (no `FORGE_*` | |
| 73 | variable or key), `--ignore-scripts` so no package runs code, `--frozen-lockfile`, | |
| 74 | and a 120 s kill. What remains is the registry and tarball fetch, which a | |
| 75 | repository's `bunfig.toml` can point at a host of its choosing; that is the same | |
| 76 | reach the agent's own `bun install` has through the sandbox's allowed hosts, and | |
| 77 | a failed or killed install only logs and lets the run start. | |
| 78 | ||
| 67 | 79 | Git hosting, code browsing, and a JSON API in one fast Bun process, with |
| 68 | 80 | **zero runtime dependencies** — the `package.json` dependency list is empty and |
| 69 | 81 | stays that way. The one exception is dev-only: `@anthropic-ai/sandbox-runtime` |
src/crew/runner.ts +19 −1
| @@ -144,7 +144,14 @@ import { trackAgentRun, untrackAgentRun } from "./running.ts"; | ||
| 144 | 144 | import { installCacheEnv, sandboxPolicy } from "./sandbox.ts"; |
| 145 | 145 | import { grantSecrets, parseDeclaration, type Grant } from "./grant.ts"; |
| 146 | 146 | import { historyHolds, scrubTree } from "./leak.ts"; |
| 147 | import { removeClone, workdirFor } from "./workdir.ts"; | |
| 147 | import { | |
| 148 | installCacheDir, | |
| 149 | installDependencies, | |
| 150 | installNote, | |
| 151 | type InstallRunner, | |
| 152 | removeClone, | |
| 153 | workdirFor, | |
| 154 | } from "./workdir.ts"; | |
| 148 | 155 | import { readersAmong, voiceOf } from "./direct.ts"; |
| 149 | 156 | import { feedbackSince } from "./news.ts"; |
| 150 | 157 | import { handedOverPulls, RETRY_SUMMARY, SKIP_SUMMARY, skipsFeedback } from "./guard.ts"; |
| @@ -178,6 +185,8 @@ export interface CrewOptions { | ||
| 178 | 185 | readonly tickMs?: number | undefined; |
| 179 | 186 | /** The clock a run's timing line reads; tests pass a fake. @default Date.now */ |
| 180 | 187 | readonly now?: (() => number) | undefined; |
| 188 | /** What runs `bun install` in a clone; tests pass a stand-in. @default bun */ | |
| 189 | readonly installRunner?: InstallRunner | undefined; | |
| 181 | 190 | } |
| 182 | 191 | |
| 183 | 192 | export { crewRoot, workdirFor } from "./workdir.ts"; |
| @@ -980,6 +989,15 @@ async function work( | ||
| 980 | 989 | ); |
| 981 | 990 | watch.lap("clone"); |
| 982 | 991 | if (!prepared.ok) return { state: "failed", summary: prepared.error }; |
| 992 | // Dependencies before the agent starts, so the type check has its types; | |
| 993 | // a failure is logged and the run goes on. | |
| 994 | const deps = await installDependencies( | |
| 995 | dir, | |
| 996 | installCacheDir(app.config.dataDir, repo.id), | |
| 997 | options.installRunner, | |
| 998 | ); | |
| 999 | const note = installNote(deps); | |
| 1000 | if (note !== undefined) write(note); | |
| 983 | 1001 | |
| 984 | 1002 | // What the agent is told. |
| 985 | 1003 | // The session belongs to the task, which spans the issue and the pull |
src/crew/workdir.ts +120 −1
| @@ -4,8 +4,9 @@ | ||
| 4 | 4 | * and the sandbox policy agree on the layout without importing each other. |
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; | |
| 7 | 8 | import { rm } from "node:fs/promises"; |
| 8 | import { join } from "node:path"; | |
| 9 | import { dirname, join } from "node:path"; | |
| 9 | 10 | import type { Agent } from "../core/agents.ts"; |
| 10 | 11 | import type { Repo } from "../core/repos.ts"; |
| 11 | 12 | |
| @@ -73,3 +74,121 @@ export async function removeClone( | ||
| 73 | 74 | } |
| 74 | 75 | } |
| 75 | 76 | } |
| 77 | ||
| 78 | const LOCKFILES = ["bun.lock", "bun.lockb"]; | |
| 79 | const INSTALLED_MARK = join("node_modules", ".forge-lock-hash"); | |
| 80 | ||
| 81 | /** Runs `bun install` in a directory with the given env; resolves to its exit code and stderr. */ | |
| 82 | export type InstallRunner = ( | |
| 83 | dir: string, | |
| 84 | env: Record<string, string>, | |
| 85 | ) => Promise<{ code: number; stderr: string }>; | |
| 86 | ||
| 87 | /** How long an install may take before it is killed and the run starts without it. */ | |
| 88 | export const INSTALL_TIMEOUT_MS = 120_000; | |
| 89 | ||
| 90 | /** | |
| 91 | * What an install sees of the forge's environment: a repository's package.json | |
outdated
No timeout: a stalled registry blocks the whole run before the agent starts. Kill the process after a bound (e.g. 120s) and return it as an error. |
||
| 92 | * is untrusted, so no FORGE_* variable, key or token goes with it. | |
| 93 | */ | |
outdated
env: {...process.env, ...env} hands every forge secret (FORGE_TOKEN, keys) to bun install and any lifecycle script a repo's trustedDependencies runs. Use an allowlisted env, and add --ignore-scripts to the args above. |
||
| 94 | export function installEnv( | |
| 95 | cacheDir: string, | |
| 96 | base: Record<string, string | undefined> = process.env, | |
| 97 | ): Record<string, string> { | |
| 98 | return { | |
| 99 | PATH: base["PATH"] ?? "/usr/local/bin:/usr/bin:/bin", | |
| 100 | HOME: join(dirname(cacheDir), "home"), | |
| 101 | TMPDIR: base["TMPDIR"] ?? "/tmp", | |
| 102 | BUN_INSTALL_CACHE_DIR: cacheDir, | |
| 103 | }; | |
| 104 | } | |
| 105 | ||
| 106 | /** Run a command with exactly `env`; killed after `timeoutMs` and reported as a failure. */ | |
| 107 | export async function runBounded( | |
| 108 | argv: string[], | |
| 109 | dir: string, | |
| 110 | env: Record<string, string>, | |
| 111 | timeoutMs: number, | |
| 112 | ): Promise<{ code: number; stderr: string }> { | |
| 113 | const proc = Bun.spawn(argv, { | |
| 114 | cwd: dir, | |
| 115 | env, | |
| 116 | stdout: "ignore", | |
| 117 | stderr: "pipe", | |
| 118 | }); | |
| 119 | let timedOut = false; | |
| 120 | const timer = setTimeout(() => { | |
| 121 | timedOut = true; | |
| 122 | proc.kill("SIGKILL"); | |
| 123 | }, timeoutMs); | |
| 124 | try { | |
| 125 | const [stderr, code] = await Promise.all([ | |
| 126 | new Response(proc.stderr).text(), | |
| 127 | proc.exited, | |
| 128 | ]); | |
| 129 | return timedOut | |
| 130 | ? { code, stderr: `timed out after ${timeoutMs} ms` } | |
| 131 | : { code, stderr }; | |
| 132 | } finally { | |
| 133 | clearTimeout(timer); | |
| 134 | } | |
| 135 | } | |
| 136 | ||
| 137 | const bunInstall: InstallRunner = (dir, env) => | |
| 138 | runBounded( | |
| 139 | ["bun", "install", "--frozen-lockfile", "--ignore-scripts"], | |
| 140 | dir, | |
| 141 | env, | |
| 142 | INSTALL_TIMEOUT_MS, | |
| 143 | ); | |
| 144 | ||
| 145 | /** The run log's line for an install's outcome, if it has one. */ | |
| 146 | export function installNote(result: { | |
| 147 | installed: boolean; | |
| 148 | error?: string; | |
| 149 | }): string | undefined { | |
| 150 | if (result.error !== undefined) | |
| 151 | return `dependency install failed: ${result.error}`; | |
| 152 | return result.installed ? "dependencies installed" : undefined; | |
| 153 | } | |
| 154 | ||
| 155 | /** | |
| 156 | * Install a clone's dependencies from the repository's shared cache before the | |
| 157 | * agent starts, so the type check has its types. Only a repository with a | |
| 158 | * package.json and a lockfile is installed; a workdir whose node_modules was | |
| 159 | * installed for the same lockfile hash is left alone. Never throws: a failure | |
| 160 | * comes back as a message for the log, and the run starts regardless. | |
| 161 | */ | |
| 162 | export async function installDependencies( | |
| 163 | dir: string, | |
| 164 | cacheDir: string, | |
| 165 | run: InstallRunner = bunInstall, | |
| 166 | ): Promise<{ installed: boolean; error?: string }> { | |
| 167 | try { | |
| 168 | const lock = LOCKFILES.map((name) => join(dir, name)).find((p) => | |
| 169 | existsSync(p), | |
| 170 | ); | |
| 171 | if (lock === undefined || !existsSync(join(dir, "package.json"))) | |
| 172 | return { installed: false }; | |
| 173 | const hash = new Bun.CryptoHasher("sha256") | |
| 174 | .update(readFileSync(lock)) | |
| 175 | .digest("hex"); | |
| 176 | const mark = join(dir, INSTALLED_MARK); | |
| 177 | if (existsSync(mark) && readFileSync(mark, "utf8") === hash) | |
| 178 | return { installed: false }; | |
| 179 | const env = installEnv(cacheDir); | |
| 180 | mkdirSync(cacheDir, { recursive: true }); | |
| 181 | mkdirSync(env["HOME"]!, { recursive: true }); | |
| 182 | const result = await run(dir, env); | |
| 183 | if (result.code !== 0) | |
| 184 | return { | |
| 185 | installed: false, | |
| 186 | error: `bun install exited ${result.code}: ${result.stderr.slice(0, 200)}`, | |
| 187 | }; | |
| 188 | mkdirSync(join(dir, "node_modules"), { recursive: true }); | |
| 189 | writeFileSync(mark, hash); | |
| 190 | return { installed: true }; | |
| 191 | } catch (error) { | |
| 192 | return { installed: false, error: String(error) }; | |
| 193 | } | |
| 194 | } |
test/crew-install.test.ts added +69
| @@ -0,0 +1,69 @@ | ||
| 1 | /** | |
| 2 | * The crew installs a clone's dependencies after the clone and before the | |
| 3 | * agent starts (#6572); a failed install is logged and the run goes on. | |
| 4 | */ | |
| 5 | ||
| 6 | import { afterAll, describe, expect, test } from "bun:test"; | |
| 7 | import { existsSync } from "node:fs"; | |
| 8 | import { join } from "node:path"; | |
| 9 | import { assignIssue, openIssue } from "../src/actions/issues.ts"; | |
| 10 | import type { InstallRunner } from "../src/crew/workdir.ts"; | |
| 11 | import { readRunLog } from "../src/runs/log.ts"; | |
| 12 | import { | |
| 13 | agentRunSettled, | |
| 14 | startStandInCrew, | |
| 15 | stopStandInCrew, | |
| 16 | type StandInCrew, | |
| 17 | } from "./helpers/crew-standin.ts"; | |
| 18 | ||
| 19 | const FILES = [ | |
| 20 | { path: "package.json", content: "{}\n" }, | |
| 21 | { path: "bun.lock", content: "lock\n" }, | |
| 22 | ]; | |
| 23 | ||
| 24 | async function oneRun(standIn: StandInCrew): Promise<string> { | |
| 25 | const { app, alice, bot, repo } = standIn; | |
| 26 | const issue = openIssue(app, alice, repo, { title: "Weed", body: "x" }); | |
| 27 | if (!issue.ok) throw new Error("issue"); | |
| 28 | expect(assignIssue(app, alice, repo, issue.value, bot).ok).toBe(true); | |
| 29 | const run = await agentRunSettled(app, repo, () => true, 30_000); | |
| 30 | return readRunLog(app.config.dataDir, run.id).lines.join("\n"); | |
| 31 | } | |
| 32 | ||
| 33 | describe("the crew installs dependencies before the agent starts", () => { | |
| 34 | const stands: StandInCrew[] = []; | |
| 35 | afterAll(async () => { | |
| 36 | for (const s of stands) await stopStandInCrew(s); | |
| 37 | }); | |
| 38 | ||
| 39 | test("the runner is called in the clone, after the clone and before the agent", async () => { | |
| 40 | const calls: { cloned: boolean; agentStarted: boolean }[] = []; | |
| 41 | let promptFile = ""; | |
| 42 | const installRunner: InstallRunner = async (dir) => { | |
| 43 | calls.push({ | |
| 44 | cloned: existsSync(join(dir, ".git")) && existsSync(join(dir, "bun.lock")), | |
| 45 | agentStarted: existsSync(promptFile), | |
| 46 | }); | |
| 47 | return { code: 0, stderr: "" }; | |
| 48 | }; | |
| 49 | const standIn = await startStandInCrew({ installRunner, files: FILES }); | |
| 50 | stands.push(standIn); | |
| 51 | promptFile = standIn.promptFile; | |
| 52 | const log = await oneRun(standIn); | |
| 53 | expect(calls).toEqual([{ cloned: true, agentStarted: false }]); | |
| 54 | expect(existsSync(promptFile)).toBe(true); | |
| 55 | expect(log).toContain("dependencies installed"); | |
| 56 | }, 60_000); | |
| 57 | ||
| 58 | test("a failing install is logged and the agent still starts", async () => { | |
| 59 | const standIn = await startStandInCrew({ | |
| 60 | installRunner: async () => ({ code: 1, stderr: "registry down" }), | |
| 61 | files: FILES, | |
| 62 | }); | |
| 63 | stands.push(standIn); | |
| 64 | const log = await oneRun(standIn); | |
| 65 | expect(log).toContain("dependency install failed"); | |
| 66 | expect(log).toContain("registry down"); | |
| 67 | expect(existsSync(standIn.promptFile)).toBe(true); | |
| 68 | }, 60_000); | |
| 69 | }); |
test/helpers/crew-standin.ts +12 −1
| @@ -22,6 +22,7 @@ import { startChecks, type ChecksHandle } from "../../src/checks/service.ts"; | ||
| 22 | 22 | import type { Repo } from "../../src/core/repos.ts"; |
| 23 | 23 | import { listRuns, type Run } from "../../src/core/runs.ts"; |
| 24 | 24 | import { createUser, type User } from "../../src/core/users.ts"; |
| 25 | import type { InstallRunner } from "../../src/crew/workdir.ts"; | |
| 25 | 26 | import { serve, type Servers } from "../../src/http/server.ts"; |
| 26 | 27 | |
| 27 | 28 | export interface StandInCrew { |
| @@ -47,6 +48,10 @@ export async function startStandInCrew( | ||
| 47 | 48 | readonly reviewBudgetMs?: number; |
| 48 | 49 | readonly tickMs?: number; |
| 49 | 50 | readonly now?: () => number; |
| 51 | /** Stands in for `bun install` in a clone. */ | |
| 52 | readonly installRunner?: InstallRunner; | |
| 53 | /** Files committed to main beside the README. */ | |
| 54 | readonly files?: { path: string; content: string }[]; | |
| 50 | 55 | } = {}, |
| 51 | 56 | ): Promise<StandInCrew> { |
| 52 | 57 | const dataDir = await mkdtemp(join(tmpdir(), "forge-crew-")); |
| @@ -184,6 +189,9 @@ echo '{"type":"result","subtype":"success","is_error":false,"result":"Added CREW | ||
| 184 | 189 | reviewBudgetMs: options.reviewBudgetMs ?? 4_000, |
| 185 | 190 | ...(options.tickMs === undefined ? {} : { tickMs: options.tickMs }), |
| 186 | 191 | ...(options.now === undefined ? {} : { now: options.now }), |
| 192 | ...(options.installRunner === undefined | |
| 193 | ? {} | |
| 194 | : { installRunner: options.installRunner }), | |
| 187 | 195 | }); |
| 188 | 196 | const a = await createUser(app.db, { |
| 189 | 197 | origin: "seed", |
| @@ -209,7 +217,10 @@ echo '{"type":"result","subtype":"success","is_error":false,"result":"Added CREW | ||
| 209 | 217 | const repo = created.value; |
| 210 | 218 | const seeded = await commitToBranch(app, alice, repo, { |
| 211 | 219 | message: "Start", |
| 212 | files: [{ path: "README.md", content: "# garden\n" }], | |
| 220 | files: [ | |
| 221 | { path: "README.md", content: "# garden\n" }, | |
| 222 | ...(options.files ?? []), | |
| 223 | ], | |
| 213 | 224 | }); |
| 214 | 225 | if (!seeded.ok) throw new Error(JSON.stringify(seeded.error)); |
| 215 | 226 | expect(addToCrew(app, alice, repo, "bot").ok).toBe(true); |
test/spawn-pipes.test.ts +1
| @@ -22,6 +22,7 @@ const PIPING: Record<string, { sites: string[]; reason: string }> = { | ||
| 22 | 22 | "src/crew/plan.ts": { sites: ["stdout"], reason: "stdout awaited with the exit; stderr ignored" }, |
| 23 | 23 | "src/crew/runner.ts": { sites: ["stdout"], reason: "lsof stdout awaited; stderr ignored" }, |
| 24 | 24 | "src/crew/sandbox.ts": { sites: ["stderr"], reason: "stderr awaited with the exit; stdout ignored" }, |
| 25 | "src/crew/workdir.ts": { sites: ["stderr"], reason: "stderr read to its end alongside the exit; stdout ignored" }, | |
| 25 | 26 | "src/git/env.ts": { sites: ["stdout+stderr", "stdout+stderr"], reason: "stdout and stderr both read to the end, finally clears the timer" }, |
| 26 | 27 | "src/git/local.ts": { sites: ["stdout+stderr", "stdout+stderr"], reason: "stdout and stderr both read to the end" }, |
| 27 | 28 | "src/git/read.ts": { sites: ["stdout"], reason: "cat-file stdout is read by the long-lived reader; stderr ignored" }, |
test/workdir.test.ts +95 −1
| @@ -1,5 +1,14 @@ | ||
| 1 | 1 | import { describe, expect, test } from "bun:test"; |
| 2 | import { removeClone } from "../src/crew/workdir.ts"; | |
| 2 | import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; | |
| 3 | import { join } from "node:path"; | |
| 4 | import { | |
| 5 | installDependencies, | |
| 6 | installEnv, | |
| 7 | installNote, | |
| 8 | runBounded, | |
| 9 | removeClone, | |
| 10 | type InstallRunner, | |
| 11 | } from "../src/crew/workdir.ts"; | |
| 3 | 12 | |
| 4 | 13 | describe("removing a clone", () => { |
| 5 | 14 | test("a removal that fails twice is tried again, and the loop turns between tries", async () => { |
| @@ -32,3 +41,88 @@ describe("removing a clone", () => { | ||
| 32 | 41 | expect(calls).toBe(3); |
| 33 | 42 | }); |
| 34 | 43 | }); |
| 44 | ||
| 45 | describe("installing dependencies", () => { | |
| 46 | const fixture = (lock: boolean) => { | |
| 47 | const dir = mkdtempSync(join(process.env["TMPDIR"] ?? "/tmp", "wd-")); | |
| 48 | writeFileSync(join(dir, "package.json"), "{}"); | |
| 49 | if (lock) writeFileSync(join(dir, "bun.lock"), "lock-a"); | |
| 50 | return dir; | |
| 51 | }; | |
| 52 | const fake = (counter: { n: number }, code = 0): InstallRunner => async (dir) => { | |
| 53 | counter.n++; | |
| 54 | mkdirSync(join(dir, "node_modules"), { recursive: true }); | |
| 55 | return { code, stderr: "boom" }; | |
| 56 | }; | |
| 57 | ||
| 58 | test("a workdir with a lockfile gets node_modules, and a second preparation does not reinstall", async () => { | |
| 59 | const dir = fixture(true); | |
| 60 | const count = { n: 0 }; | |
| 61 | const cache = join(dir, ".cache"); | |
| 62 | expect((await installDependencies(dir, cache, fake(count))).installed).toBe(true); | |
| 63 | expect(existsSync(join(dir, "node_modules"))).toBe(true); | |
| 64 | expect((await installDependencies(dir, cache, fake(count))).installed).toBe(false); | |
| 65 | expect(count.n).toBe(1); | |
| 66 | writeFileSync(join(dir, "bun.lock"), "lock-b"); | |
| 67 | await installDependencies(dir, cache, fake(count)); | |
| 68 | expect(count.n).toBe(2); | |
| 69 | }); | |
| 70 | ||
| 71 | test("no lockfile means no install; a failed install is reported, not thrown", async () => { | |
| 72 | const count = { n: 0 }; | |
| 73 | await installDependencies(fixture(false), "/x", fake(count)); | |
| 74 | expect(count.n).toBe(0); | |
| 75 | const dir = fixture(true); | |
| 76 | const result = await installDependencies(dir, join(dir, ".c"), fake(count, 1)); | |
| 77 | expect(result.error).toContain("exited 1"); | |
| 78 | const thrown = await installDependencies(dir, join(dir, ".c"), async () => { | |
| 79 | throw new Error("spawn"); | |
| 80 | }); | |
| 81 | expect(thrown.error).toContain("spawn"); | |
| 82 | }); | |
| 83 | }); | |
| 84 | ||
| 85 | describe("the install's bounds", () => { | |
| 86 | test("the child env is an allowlist: no FORGE_* variable, no token", () => { | |
| 87 | const env = installEnv("/c/bun", { | |
| 88 | PATH: "/bin", | |
| 89 | TMPDIR: "/t", | |
| 90 | FORGE_TOKEN: "s", | |
| 91 | FORGE_SECRET: "k", | |
| 92 | ANTHROPIC_API_KEY: "a", | |
| 93 | }); | |
| 94 | expect(Object.keys(env).sort()).toEqual(["BUN_INSTALL_CACHE_DIR", "HOME", "PATH", "TMPDIR"]); | |
| 95 | expect(Object.keys(installEnv("/c/bun")).some((k) => k.startsWith("FORGE_"))).toBe(false); | |
| 96 | }); | |
| 97 | ||
| 98 | test("the runner is handed that env, not the forge's", async () => { | |
| 99 | process.env["FORGE_TOKEN"] = "secret"; | |
| 100 | let seen: Record<string, string> = {}; | |
| 101 | try { | |
| 102 | const dir = mkdtempSync(join(process.env["TMPDIR"] ?? "/tmp", "wd-")); | |
| 103 | writeFileSync(join(dir, "package.json"), "{}"); | |
| 104 | writeFileSync(join(dir, "bun.lock"), "x"); | |
| 105 | await installDependencies(dir, join(dir, "c", "bun"), async (_d, env) => { | |
| 106 | seen = env; | |
| 107 | return { code: 0, stderr: "" }; | |
| 108 | }); | |
| 109 | } finally { | |
| 110 | delete process.env["FORGE_TOKEN"]; | |
| 111 | } | |
| 112 | expect(Object.keys(seen).some((k) => k.startsWith("FORGE_"))).toBe(false); | |
| 113 | }); | |
| 114 | ||
| 115 | test("a hung install is killed and reported", async () => { | |
| 116 | const started = Date.now(); | |
| 117 | const result = await runBounded(["sleep", "30"], ".", { PATH: process.env["PATH"] ?? "" }, 100); | |
| 118 | expect(Date.now() - started).toBeLessThan(5000); | |
| 119 | expect(result.code).not.toBe(0); | |
| 120 | expect(result.stderr).toContain("timed out"); | |
| 121 | }); | |
| 122 | ||
| 123 | test("the run log's line for each outcome", () => { | |
| 124 | expect(installNote({ installed: true })).toBe("dependencies installed"); | |
| 125 | expect(installNote({ installed: false })).toBeUndefined(); | |
| 126 | expect(installNote({ installed: false, error: "boom" })).toBe("dependency install failed: boom"); | |
| 127 | }); | |
| 128 | }); |
new above
offline
This wiring is the feature, and no test covers it. Extract it or inject the runner so a test can prove the order (clone, install, agent) and that a failed install does not stop the run.