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
126 lines
4.6 KiB
TypeScript
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);
|
|
});
|