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:
@@ -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
|
||||
|
||||
18
apps/runner/src/exec.mock.ts
Normal file
18
apps/runner/src/exec.mock.ts
Normal file
@@ -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<CommandResult> = {}): {
|
||||
run: RunCommand;
|
||||
calls: unknown[][];
|
||||
} {
|
||||
const calls: unknown[][] = [];
|
||||
const run: RunCommand = async (...args) => {
|
||||
calls.push(args);
|
||||
return { exitCode: 0, stdout: "", stderr: "", ...result };
|
||||
};
|
||||
return { run, calls };
|
||||
}
|
||||
@@ -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<CommandMessage["type"], "clone">,
|
||||
Exclude<CommandMessage["type"], "clone" | "install">,
|
||||
{ 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(
|
||||
|
||||
@@ -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<CommandResult>): {
|
||||
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,
|
||||
|
||||
160
apps/runner/src/stages/install.test.ts
Normal file
160
apps/runner/src/stages/install.test.ts
Normal file
@@ -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<string, string>): Promise<string> {
|
||||
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"),
|
||||
});
|
||||
});
|
||||
});
|
||||
110
apps/runner/src/stages/install.ts
Normal file
110
apps/runner/src/stages/install.ts
Normal file
@@ -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<PackageManager> {
|
||||
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<PackageManager | null> {
|
||||
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<void> => {
|
||||
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)}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
138
apps/runner/tests/install-stage.integration.test.ts
Normal file
138
apps/runner/tests/install-stage.integration.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
53
apps/runner/tests/runner-session.ts
Normal file
53
apps/runner/tests/runner-session.ts
Normal 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 });
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user