Files
agentic-dev/apps/runner/tests/runner-process.integration.test.ts
Danijel Martinek ec7bf948af feat(runner): install stage with progress events
Install stage: package-manager detection (lockfile beats the
packageManager field, npm default — vite-kitchen's shape), install run
inside the clone with staged status heartbeats, and every failure —
including install-before-clone — mapped to the named install-failed
cause with a bounded output tail. The spawned-runner suite now runs the
full clone → install pipeline on the daemon-served vite-kitchen (real
npm registry install) and greps the child's entire stdout+stderr plus
the clone's .git/config for the PAT and workspace token. Shared test
doubles extracted (exec.mock.ts, tests/runner-session.ts) to keep the
suites duplication-free.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016j8z4VHjedXDTjEDNg7qHK
2026-07-12 22:50:41 +02:00

126 lines
4.6 KiB
TypeScript

/**
* 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", () => {
it("boots from env config, announces its port, and completes the handshake over real WS", async () => {
runner = await spawnRunner({ token: TOKEN });
expect(runner.port).toBeGreaterThan(0);
const client = await connectProtocolClient(runner.port, TOKEN);
clients.push(client);
const ready = await handshake(client);
expect(ready).toEqual({ type: "ready" });
});
it("rejects a bad workspace token from a real client and closes the socket", async () => {
runner = await spawnRunner({ token: TOKEN });
const client = await connectProtocolClient(runner.port, "not-the-token");
clients.push(client);
client.send({ type: "hello" });
await expectNamedError(client, "unauthorized");
await expect(client.closed).resolves.toBe(1008);
// 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);
});