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
This commit is contained in:
2026-07-12 22:44:39 +02:00
parent 750ab44379
commit ec7bf948af
10 changed files with 612 additions and 65 deletions

View File

@@ -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<ProtocolClient> {
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<string> {
* `.git/config`, or any other file under `.git/`.
*/
async function expectNoCredentialLeak(cloneDir: string): Promise<void> {
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 () => {

View File

@@ -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<ProtocolClient> {
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<string> {
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<void> {
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");
});
});

View File

@@ -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);
});

View File

@@ -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<void>;
}
export async function startRunnerSession(options: {
token: string;
tmpPrefix: string;
heartbeatMs?: number;
}): Promise<RunnerSession> {
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 });
},
};
}