diff --git a/apps/runner/AGENTS.md b/apps/runner/AGENTS.md index 3682d66..d8f2d7c 100644 --- a/apps/runner/AGENTS.md +++ b/apps/runner/AGENTS.md @@ -63,6 +63,25 @@ What is NOT covered: a real smart-HTTP provider (GitHub et al.) — the dumb-HTTP fallback exercises the same credential machinery in git, but the smart-protocol surface itself first meets reality in later PRDs. +## Install stage + +- Package-manager detection (`src/stages/install.ts`): lockfile first + (pnpm-lock.yaml / yarn.lock / bun.lockb / bun.lock / + package-lock.json / npm-shrinkwrap.json — the lockfile pins install + semantics, so it beats a contradicting `packageManager` field), then + the `packageManager` field, then npm as the default (vite-kitchen's + shape: no lockfile, no field). +- The detected manager runs `install` in the clone (`npm` adds + `--no-audit --no-fund`), streaming staged `status` heartbeats; every + failure — including "install before clone" — is the named + `install-failed` cause with a bounded output tail as the message. +- The in-process integration suite covers success on a minimal + no-dependency fixture (real npm, instant, offline) and the named + failure via a fixture depending on a package that cannot exist; the + spawned-runner suite runs the full clone → install pipeline on + vite-kitchen (real registry install) with the leak grep over the + child's entire output. + ## Testing - Unit suites live next to sources in `src/`; protocol/WS suites live in diff --git a/apps/runner/src/exec.mock.ts b/apps/runner/src/exec.mock.ts new file mode 100644 index 0000000..d44a13c --- /dev/null +++ b/apps/runner/src/exec.mock.ts @@ -0,0 +1,18 @@ +import type { CommandResult, RunCommand } from "./exec"; + +/** + * Recording `RunCommand` double for stage unit tests (`.mock.ts` sibling + * per repo convention). Resolves every spawn with `result` merged over a + * clean zero-exit and records each `(command, args, options)` call. + */ +export function mockRunCommand(result: Partial = {}): { + run: RunCommand; + calls: unknown[][]; +} { + const calls: unknown[][] = []; + const run: RunCommand = async (...args) => { + calls.push(args); + return { exitCode: 0, stdout: "", stderr: "", ...result }; + }; + return { run, calls }; +} diff --git a/apps/runner/src/server.ts b/apps/runner/src/server.ts index a3f606d..9722134 100644 --- a/apps/runner/src/server.ts +++ b/apps/runner/src/server.ts @@ -11,6 +11,7 @@ import { import { runCommand } from "./exec"; import type { Logger } from "./log"; import { cloneRepository } from "./stages/clone"; +import { installDependencies } from "./stages/install"; import { runStage, type StageEmitter } from "./stages/stage-runner"; /** @@ -142,10 +143,9 @@ export type CommandMessage = Extract< /** Stages that later walking-skeleton stories wire up (05+). */ const NOT_IMPLEMENTED: Record< - Exclude, + Exclude, { cause: RunnerErrorCause; stage?: RunnerStage } > = { - install: { cause: "install-failed", stage: "installing" }, scan: { cause: "scan-failed", stage: "scanning" }, "adapter-start": { cause: "adapter-start-failed", stage: "starting-preview" }, "render-frame": { cause: "render-failed" }, @@ -184,6 +184,20 @@ async function handleCommand( if (result.ok) ctx.ready(); return; } + case "install": { + const result = await runStage( + "installing", + "install-failed", + stageOptions, + installDependencies({ + workspaceDir: options.workspaceDir, + runCommand, + log: options.log, + }), + ); + if (result.ok) ctx.ready(); + return; + } default: { const notImplemented = NOT_IMPLEMENTED[command.type]; emit.error( diff --git a/apps/runner/src/stages/clone.test.ts b/apps/runner/src/stages/clone.test.ts index 71cff2b..5faa789 100644 --- a/apps/runner/src/stages/clone.test.ts +++ b/apps/runner/src/stages/clone.test.ts @@ -13,22 +13,10 @@ import { import { StageError } from "@/stages/stage-runner"; import { PAT_ENV_VAR } from "@/git/credential-helper"; import { createLogger } from "@/log"; -import type { CommandResult, RunCommand } from "@/exec"; +import { mockRunCommand } from "@/exec.mock"; const PAT = "ghp_never-in-argv-or-config"; -function fakeRun(result: Partial): { - run: RunCommand; - calls: unknown[][]; -} { - const calls: unknown[][] = []; - const run: RunCommand = async (...args) => { - calls.push(args); - return { exitCode: 0, stdout: "", stderr: "", ...result }; - }; - return { run, calls }; -} - describe("validateGitUrl", () => { it("accepts git, http(s), and file URLs", () => { expect(validateGitUrl("git://127.0.0.1:9418/vite-kitchen.git")).toBeNull(); @@ -141,7 +129,7 @@ describe("mapCloneFailure", () => { describe("cloneRepository", () => { it("rejects an invalid URL before ever spawning git", async () => { - const { run, calls } = fakeRun({}); + const { run, calls } = mockRunCommand({}); const clone = cloneRepository({ workspaceDir: "/ws", runCommand: run, @@ -160,7 +148,7 @@ describe("cloneRepository", () => { const workspaceDir = await mkdtemp( path.join(os.tmpdir(), "veect-clone-unit-"), ); - const { run, calls } = fakeRun({}); + const { run, calls } = mockRunCommand({}); const clone = cloneRepository({ workspaceDir, runCommand: run, @@ -193,7 +181,7 @@ describe("cloneRepository", () => { const stale = path.join(repoPath(workspaceDir), "stale.txt"); await mkdir(repoPath(workspaceDir), { recursive: true }); await writeFile(stale, "old"); - const { run } = fakeRun({}); + const { run } = mockRunCommand({}); const clone = cloneRepository({ workspaceDir, runCommand: run, @@ -209,7 +197,7 @@ describe("cloneRepository", () => { const workspaceDir = await mkdtemp( path.join(os.tmpdir(), "veect-clone-unit-"), ); - const { run } = fakeRun({ + const { run } = mockRunCommand({ exitCode: 128, stderr: "fatal: Authentication failed for 'https://x.test/a.git/'", }); @@ -231,7 +219,7 @@ describe("cloneRepository", () => { path.join(os.tmpdir(), "veect-clone-unit-"), ); const lines: string[] = []; - const { run } = fakeRun({}); + const { run } = mockRunCommand({}); const clone = cloneRepository({ workspaceDir, runCommand: run, diff --git a/apps/runner/src/stages/install.test.ts b/apps/runner/src/stages/install.test.ts new file mode 100644 index 0000000..316abd2 --- /dev/null +++ b/apps/runner/src/stages/install.test.ts @@ -0,0 +1,160 @@ +import { mkdir, mkdtemp, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { + buildInstallArgs, + detectPackageManager, + installDependencies, +} from "@/stages/install"; +import { repoPath } from "@/stages/clone"; +import { createLogger } from "@/log"; +import { mockRunCommand } from "@/exec.mock"; + +async function makeRepo(files: Record): Promise { + const workspaceDir = await mkdtemp( + path.join(os.tmpdir(), "veect-install-unit-"), + ); + const repoDir = repoPath(workspaceDir); + await mkdir(repoDir, { recursive: true }); + for (const [name, content] of Object.entries(files)) { + await writeFile(path.join(repoDir, name), content); + } + return workspaceDir; +} + +const PKG = JSON.stringify({ name: "fixture", private: true }); + +describe("detectPackageManager", () => { + it.each([ + ["pnpm-lock.yaml", "pnpm"], + ["yarn.lock", "yarn"], + ["bun.lockb", "bun"], + ["bun.lock", "bun"], + ["package-lock.json", "npm"], + ["npm-shrinkwrap.json", "npm"], + ] as const)("detects %s → %s", async (lockfile, expected) => { + const workspaceDir = await makeRepo({ + "package.json": PKG, + [lockfile]: "", + }); + await expect(detectPackageManager(repoPath(workspaceDir))).resolves.toBe( + expected, + ); + }); + + it("falls back to the packageManager field when no lockfile exists", async () => { + const workspaceDir = await makeRepo({ + "package.json": JSON.stringify({ + name: "x", + packageManager: "pnpm@9.15.4", + }), + }); + await expect(detectPackageManager(repoPath(workspaceDir))).resolves.toBe( + "pnpm", + ); + }); + + it("prefers the lockfile over a contradicting packageManager field", async () => { + const workspaceDir = await makeRepo({ + "package.json": JSON.stringify({ + name: "x", + packageManager: "pnpm@9.0.0", + }), + "yarn.lock": "", + }); + await expect(detectPackageManager(repoPath(workspaceDir))).resolves.toBe( + "yarn", + ); + }); + + it("defaults to npm with no lockfile and no field (vite-kitchen's shape)", async () => { + const workspaceDir = await makeRepo({ "package.json": PKG }); + await expect(detectPackageManager(repoPath(workspaceDir))).resolves.toBe( + "npm", + ); + }); + + it("ignores an unknown packageManager value", async () => { + const workspaceDir = await makeRepo({ + "package.json": JSON.stringify({ + name: "x", + packageManager: "deno@2.0.0", + }), + }); + await expect(detectPackageManager(repoPath(workspaceDir))).resolves.toBe( + "npm", + ); + }); +}); + +describe("buildInstallArgs", () => { + it("runs npm without audit/fund noise; others with plain install", () => { + expect(buildInstallArgs("npm")).toEqual([ + "install", + "--no-audit", + "--no-fund", + ]); + expect(buildInstallArgs("pnpm")).toEqual(["install"]); + expect(buildInstallArgs("yarn")).toEqual(["install"]); + expect(buildInstallArgs("bun")).toEqual(["install"]); + }); +}); + +describe("installDependencies", () => { + it("fails with the named cause when nothing has been cloned", async () => { + const workspaceDir = await mkdtemp( + path.join(os.tmpdir(), "veect-install-unit-"), + ); + const { run, calls } = mockRunCommand({}); + const install = installDependencies({ + workspaceDir, + runCommand: run, + log: createLogger(() => undefined), + }); + + await expect(install()).rejects.toMatchObject({ + namedCause: "install-failed", + message: expect.stringContaining("run clone first"), + }); + expect(calls).toHaveLength(0); + }); + + it("runs the detected package manager inside the clone", async () => { + const workspaceDir = await makeRepo({ "package.json": PKG }); + const { run, calls } = mockRunCommand({}); + const install = installDependencies({ + workspaceDir, + runCommand: run, + log: createLogger(() => undefined), + }); + + await install(); + + expect(calls).toEqual([ + [ + "npm", + ["install", "--no-audit", "--no-fund"], + { cwd: repoPath(workspaceDir) }, + ], + ]); + }); + + it("maps a non-zero exit to the named install-failed cause with an output tail", async () => { + const workspaceDir = await makeRepo({ "package.json": PKG }); + const { run } = mockRunCommand({ + exitCode: 1, + stderr: "npm error 404 Not Found - GET https://registry.npmjs.org/nope", + }); + const install = installDependencies({ + workspaceDir, + runCommand: run, + log: createLogger(() => undefined), + }); + + await expect(install()).rejects.toMatchObject({ + namedCause: "install-failed", + message: expect.stringContaining("404"), + }); + }); +}); diff --git a/apps/runner/src/stages/install.ts b/apps/runner/src/stages/install.ts new file mode 100644 index 0000000..f797792 --- /dev/null +++ b/apps/runner/src/stages/install.ts @@ -0,0 +1,110 @@ +import { readFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import path from "node:path"; +import type { RunCommand } from "../exec"; +import type { Logger } from "../log"; +import { repoPath } from "./clone"; +import { StageError } from "./stage-runner"; + +/** + * Package managers the runner can drive (spec §6 "Dependency install"). + * Detection order: lockfile (the strongest signal — it pins the exact + * install semantics) → `packageManager` field → npm default. + */ +export type PackageManager = "npm" | "pnpm" | "yarn" | "bun"; + +const LOCKFILES: readonly [string, PackageManager][] = [ + ["pnpm-lock.yaml", "pnpm"], + ["yarn.lock", "yarn"], + ["bun.lockb", "bun"], + ["bun.lock", "bun"], + ["package-lock.json", "npm"], + ["npm-shrinkwrap.json", "npm"], +]; + +const PACKAGE_MANAGER_NAMES: readonly PackageManager[] = [ + "npm", + "pnpm", + "yarn", + "bun", +]; + +/** Detect the repo's package manager. Pure over the clone's file layout. */ +export async function detectPackageManager( + repoDir: string, +): Promise { + for (const [lockfile, packageManager] of LOCKFILES) { + if (existsSync(path.join(repoDir, lockfile))) return packageManager; + } + const declared = await readPackageManagerField(repoDir); + return declared ?? "npm"; +} + +async function readPackageManagerField( + repoDir: string, +): Promise { + try { + const raw = await readFile(path.join(repoDir, "package.json"), "utf8"); + const parsed = JSON.parse(raw) as { packageManager?: unknown }; + if (typeof parsed.packageManager !== "string") return null; + const name = parsed.packageManager.split("@")[0]; + const match = PACKAGE_MANAGER_NAMES.find((candidate) => candidate === name); + return match ?? null; + } catch { + return null; + } +} + +/** The exact argv the runner runs for each package manager. */ +export function buildInstallArgs(packageManager: PackageManager): string[] { + if (packageManager === "npm") return ["install", "--no-audit", "--no-fund"]; + return ["install"]; +} + +/** Bounded, single-line tail of the failed install's output. */ +function outputTail(stdout: string, stderr: string): string { + const flat = `${stdout}\n${stderr}` + .trim() + .split("\n") + .map((line) => line.trim()) + .filter(Boolean) + .slice(-8) + .join(" | "); + const bounded = flat.length > 400 ? `…${flat.slice(-400)}` : flat; + return bounded || "install failed with no output"; +} + +export interface InstallStageDeps { + workspaceDir: string; + runCommand: RunCommand; + log: Logger; +} + +/** + * The install stage: requires a clone with a `package.json`, detects the + * package manager, runs its install in the clone. Every failure throws + * `StageError("install-failed", …)` — the protocol's named cause. + */ +export function installDependencies(deps: InstallStageDeps) { + return async (): Promise => { + const repoDir = repoPath(deps.workspaceDir); + if (!existsSync(path.join(repoDir, "package.json"))) { + throw new StageError( + "install-failed", + "no cloned repository with a package.json — run clone first", + ); + } + const packageManager = await detectPackageManager(repoDir); + const args = buildInstallArgs(packageManager); + deps.log.info("install-spawn", { packageManager, args }); + const result = await deps.runCommand(packageManager, args, { + cwd: repoDir, + }); + if (result.exitCode !== 0) { + throw new StageError( + "install-failed", + `${packageManager} install failed: ${outputTail(result.stdout, result.stderr)}`, + ); + } + }; +} diff --git a/apps/runner/tests/clone-stage.integration.test.ts b/apps/runner/tests/clone-stage.integration.test.ts index ccd98d2..b70e0b9 100644 --- a/apps/runner/tests/clone-stage.integration.test.ts +++ b/apps/runner/tests/clone-stage.integration.test.ts @@ -18,34 +18,22 @@ * clone's stored remote URL. */ import { existsSync } from "node:fs"; -import { mkdtemp, readdir, readFile, rm } from "node:fs/promises"; -import os from "node:os"; +import { readdir, readFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { - afterAll, - afterEach, - beforeAll, - beforeEach, - describe, - expect, - it, -} from "vitest"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; import { serveFixtureRepo, type ServedFixtureRepo, } from "@repo/core-testing/git"; -import { startRunnerServer, type RunnerServer } from "@/server"; -import { createLogger } from "@/log"; import { repoPath } from "@/stages/clone"; import { PAT_USERNAME } from "@/git/credential-helper"; import { - connectProtocolClient, expectNamedError, - handshake, sendAndAwaitReady, type ProtocolClient, } from "./protocol-client"; +import { startRunnerSession, type RunnerSession } from "./runner-session"; import { serveBareRepoOverHttp, type AuthedGitHttpServer, @@ -72,36 +60,18 @@ afterAll(async () => { await served.stop(); }); -let server: RunnerServer; -let workspaceDir: string; -let logLines: string[]; -let clients: ProtocolClient[]; +let session: RunnerSession; async function startSession(): Promise { - workspaceDir = await mkdtemp(path.join(os.tmpdir(), "veect-clone-int-")); - logLines = []; - server = await startRunnerServer({ - host: "127.0.0.1", - port: 0, + session = await startRunnerSession({ token: TOKEN, - workspaceDir, - heartbeatMs: 100, - log: createLogger((line) => logLines.push(line)), + tmpPrefix: "veect-clone-int-", }); - const client = await connectProtocolClient(server.port, TOKEN); - clients.push(client); - await handshake(client); - return client; + return session.client; } -beforeEach(() => { - clients = []; -}); - afterEach(async () => { - for (const client of clients) client.close(); - await server.close(); - await rm(workspaceDir, { recursive: true, force: true }); + await session.close(); }); /** Every file under the clone's .git that could persist a credential. */ @@ -129,7 +99,7 @@ async function gitDirLeakSurface(cloneDir: string): Promise { * `.git/config`, or any other file under `.git/`. */ async function expectNoCredentialLeak(cloneDir: string): Promise { - expect(logLines.join("\n")).not.toContain(PAT); + expect(session.logLines.join("\n")).not.toContain(PAT); const gitConfig = await readFile( path.join(cloneDir, ".git", "config"), "utf8", @@ -166,7 +136,7 @@ describe("clone via git daemon (primary transport)", () => { // The clone actually landed, and the stored remote URL is the // credential-free URL, verbatim. - const cloneDir = repoPath(workspaceDir); + const cloneDir = repoPath(session.workspaceDir); expect(existsSync(path.join(cloneDir, "package.json"))).toBe(true); const gitConfig = await readFile( path.join(cloneDir, ".git", "config"), @@ -193,7 +163,7 @@ describe("clone via git daemon (primary transport)", () => { client.send({ type: "clone", gitUrl: "not a url at all" }); const error = await expectNamedError(client, "invalid-git-url"); expect(error.stage).toBe("cloning"); - expect(logLines.join("\n")).not.toContain("git-spawn"); + expect(session.logLines.join("\n")).not.toContain("git-spawn"); }); it("keeps the session usable after a failed clone (corrected retry succeeds)", async () => { @@ -205,9 +175,9 @@ describe("clone via git daemon (primary transport)", () => { { type: "clone", gitUrl: served.cloneUrl }, "retried clone completion", ); - expect(existsSync(path.join(repoPath(workspaceDir), "package.json"))).toBe( - true, - ); + expect( + existsSync(path.join(repoPath(session.workspaceDir), "package.json")), + ).toBe(true); }); }); @@ -241,7 +211,7 @@ describe("clone via authenticated HTTP (credential-helper mechanics)", () => { ).toBe(true); // And the credential persisted nowhere. - await expectNoCredentialLeak(repoPath(workspaceDir)); + await expectNoCredentialLeak(repoPath(session.workspaceDir)); }); it("emits the named auth-failed failure for a wrong PAT", async () => { diff --git a/apps/runner/tests/install-stage.integration.test.ts b/apps/runner/tests/install-stage.integration.test.ts new file mode 100644 index 0000000..3d49756 --- /dev/null +++ b/apps/runner/tests/install-stage.integration.test.ts @@ -0,0 +1,138 @@ +/** + * Install-stage integration suite: real WS protocol session, real + * package-manager subprocesses. + * + * The success path installs a minimal no-dependency fixture (a real + * `npm install` — seconds, no network) so the path is covered + * in-process; the full vite-kitchen clone → install pipeline (real + * registry install, minutes cold) runs once against the SPAWNED runner + * in `runner-process.integration.test.ts`. The failure path installs a + * fixture depending on a package that cannot exist → the named + * `install-failed` event. + */ +import { existsSync } from "node:fs"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + serveFixtureRepo, + type ServedFixtureRepo, +} from "@repo/core-testing/git"; +import { repoPath } from "@/stages/clone"; +import { + expectNamedError, + sendAndAwaitReady, + type ProtocolClient, +} from "./protocol-client"; +import { startRunnerSession, type RunnerSession } from "./runner-session"; + +const TOKEN = "install-suite-token"; + +let session: RunnerSession; +let served: ServedFixtureRepo | undefined; +let fixtureDir: string | undefined; + +async function startSession(): Promise { + session = await startRunnerSession({ + token: TOKEN, + tmpPrefix: "veect-install-int-", + }); + return session.client; +} + +/** Write a synthetic one-package fixture and serve it as a git remote. */ +async function serveSyntheticFixture(packageJson: object): Promise { + fixtureDir = await mkdtemp(path.join(os.tmpdir(), "veect-install-fixture-")); + await mkdir(fixtureDir, { recursive: true }); + await writeFile( + path.join(fixtureDir, "package.json"), + JSON.stringify(packageJson, null, 2), + ); + served = await serveFixtureRepo(fixtureDir, { protocol: "file" }); + return served.cloneUrl; +} + +afterEach(async () => { + await session.close(); + await served?.stop(); + served = undefined; + if (fixtureDir !== undefined) { + await rm(fixtureDir, { recursive: true, force: true }); + fixtureDir = undefined; + } +}); + +async function cloneAndWait( + client: ProtocolClient, + gitUrl: string, +): Promise { + await sendAndAwaitReady( + client, + { type: "clone", gitUrl }, + "clone completion", + ); +} + +describe("install stage", () => { + it("detects npm, installs, and streams staged installing progress", async () => { + const cloneUrl = await serveSyntheticFixture({ + name: "veect-install-success-fixture", + private: true, + version: "0.0.0", + // No dependencies: the install is real npm, but instant + offline. + }); + const client = await startSession(); + await cloneAndWait(client, cloneUrl); + + await sendAndAwaitReady(client, { type: "install" }, "install completion"); + + const statuses = client + .received() + .flatMap((m) => + m.type === "status" && m.stage === "installing" ? [m] : [], + ); + expect(statuses[0]).toEqual({ + type: "status", + stage: "installing", + elapsedMs: 0, + }); + const elapsed = statuses.map((s) => s.elapsedMs); + expect([...elapsed].sort((a, b) => a - b)).toEqual(elapsed); + expect(session.logLines.join("\n")).toContain('"packageManager":"npm"'); + // npm actually ran in the clone. + expect( + existsSync( + path.join(repoPath(session.workspaceDir), "package-lock.json"), + ) || + existsSync(path.join(repoPath(session.workspaceDir), "node_modules")), + ).toBe(true); + }); + + it("emits the named install-failed event when the install errors", async () => { + const cloneUrl = await serveSyntheticFixture({ + name: "veect-install-failure-fixture", + private: true, + version: "0.0.0", + dependencies: { + // Scoped to a namespace that cannot exist on the public registry. + "@veect-test-nonexistent/definitely-not-a-package": "^99.99.99", + }, + }); + const client = await startSession(); + await cloneAndWait(client, cloneUrl); + + client.send({ type: "install" }); + const error = await expectNamedError(client, "install-failed"); + expect(error.stage).toBe("installing"); + expect(error.message.length).toBeGreaterThan(0); + }); + + it("emits the named install-failed event when nothing has been cloned", async () => { + const client = await startSession(); + client.send({ type: "install" }); + const error = await expectNamedError(client, "install-failed"); + expect(error.stage).toBe("installing"); + expect(error.message).toContain("run clone first"); + }); +}); diff --git a/apps/runner/tests/runner-process.integration.test.ts b/apps/runner/tests/runner-process.integration.test.ts index da33eb8..e7b020d 100644 --- a/apps/runner/tests/runner-process.integration.test.ts +++ b/apps/runner/tests/runner-process.integration.test.ts @@ -2,26 +2,51 @@ * Spawn-based integration suite: the runner as the provisioner (story 06) * will actually run it — a real child process, config via env only, port * discovered from stdout, real WS from the outside. + * + * The full pipeline test is the story's M0-recast acceptance seam: a + * `git daemon`-served vite-kitchen, cloned and npm-installed by the + * spawned runner (real registry traffic — minutes on a cold cache), with + * the credential-leak grep running over the child process's ENTIRE + * stdout+stderr and the resulting clone's `.git/config`. */ +import { existsSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; import { afterEach, describe, expect, it } from "vitest"; +import { + serveFixtureRepo, + type ServedFixtureRepo, +} from "@repo/core-testing/git"; import { spawnRunner, type SpawnedRunner } from "./spawn-runner"; import { connectProtocolClient, expectNamedError, handshake, + sendAndAwaitReady, type ProtocolClient, } from "./protocol-client"; const TOKEN = "spawned-runner-token"; +const PAT = "ghp_spawned-pipeline-pat-51c2"; + +/** repo-root/fixtures/vite-kitchen, resolved from this file's location. */ +const FIXTURE_DIR = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../../../fixtures/vite-kitchen", +); let runner: SpawnedRunner | undefined; let clients: ProtocolClient[] = []; +let served: ServedFixtureRepo | undefined; afterEach(async () => { for (const client of clients) client.close(); clients = []; await runner?.stop(); runner = undefined; + await served?.stop(); + served = undefined; }); describe("spawned runner process", () => { @@ -45,4 +70,56 @@ describe("spawned runner process", () => { // The runner never writes the workspace token to its logs. expect(runner.output()).not.toContain(TOKEN); }); + + it("runs the clone → install pipeline on vite-kitchen without leaking the credential", async () => { + served = await serveFixtureRepo(FIXTURE_DIR); + runner = await spawnRunner({ token: TOKEN }); + const client = await connectProtocolClient(runner.port, TOKEN); + clients.push(client); + await handshake(client); + + await sendAndAwaitReady( + client, + { type: "clone", gitUrl: served.cloneUrl, pat: PAT }, + "spawned clone completion", + ); + // Real npm install of vite-kitchen (react, vite, tailwind, …): + // minutes-scale on a cold cache — this is the honest cost of the + // story's acceptance seam. + await sendAndAwaitReady( + client, + { type: "install" }, + "spawned install completion", + 220_000, + ); + + // Staged progress arrived for both stages, elapsed nondecreasing per stage. + for (const stage of ["cloning", "installing"] as const) { + const statuses = client + .received() + .flatMap((m) => (m.type === "status" && m.stage === stage ? [m] : [])); + expect(statuses.length).toBeGreaterThanOrEqual(2); + expect(statuses[0]?.elapsedMs).toBe(0); + const elapsed = statuses.map((s) => s.elapsedMs); + expect([...elapsed].sort((a, b) => a - b)).toEqual(elapsed); + } + + // The install really happened: the fixture's dependencies exist. + const repoDir = path.join(runner.workspaceDir, "repo"); + expect(existsSync(path.join(repoDir, "node_modules", "react"))).toBe(true); + expect(existsSync(path.join(repoDir, "node_modules", "vite"))).toBe(true); + + // Leak assertions over the REAL process boundary (spec §15): the + // PAT and the workspace token appear nowhere in the child's whole + // stdout+stderr (which includes every logged git argv), nor in the + // clone's .git/config. + expect(runner.output()).not.toContain(PAT); + expect(runner.output()).not.toContain(TOKEN); + const gitConfig = await readFile( + path.join(repoDir, ".git", "config"), + "utf8", + ); + expect(gitConfig).not.toContain(PAT); + expect(gitConfig).toContain(served.cloneUrl); + }, 300_000); }); diff --git a/apps/runner/tests/runner-session.ts b/apps/runner/tests/runner-session.ts new file mode 100644 index 0000000..6216848 --- /dev/null +++ b/apps/runner/tests/runner-session.ts @@ -0,0 +1,53 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { createLogger } from "@/log"; +import { startRunnerServer, type RunnerServer } from "@/server"; +import { + connectProtocolClient, + handshake, + type ProtocolClient, +} from "./protocol-client"; + +/** + * An in-process runner server plus one already-handshaken client, with + * log capture (for the credential-leak assertions) and one-call + * teardown. In-process so v8 coverage sees the server; the spawn suite + * covers the real-child-process shape. + */ +export interface RunnerSession { + client: ProtocolClient; + workspaceDir: string; + /** Every log line the runner emitted — greppable for leaks. */ + logLines: string[]; + close: () => Promise; +} + +export async function startRunnerSession(options: { + token: string; + tmpPrefix: string; + heartbeatMs?: number; +}): Promise { + const workspaceDir = await mkdtemp(path.join(os.tmpdir(), options.tmpPrefix)); + const logLines: string[] = []; + const server: RunnerServer = await startRunnerServer({ + host: "127.0.0.1", + port: 0, + token: options.token, + workspaceDir, + heartbeatMs: options.heartbeatMs ?? 100, + log: createLogger((line) => logLines.push(line)), + }); + const client = await connectProtocolClient(server.port, options.token); + await handshake(client); + return { + client, + workspaceDir, + logLines, + close: async () => { + client.close(); + await server.close(); + await rm(workspaceDir, { recursive: true, force: true }); + }, + }; +}