// @vitest-environment node
/*
RFC-1086: Unit tests for --fast flag incremental validation in mission.validate.
- Not a contract source of truth — the module under test owns the authoritative behavior; fixtures are minimal scaffolding only.
- RFC-1086: initial unit tests for --fast flag — skip passed phases, cascade re-run, fallback on missing/corrupt/mismatched report.
- RFC-1149: migrate ambient filesystem and process IO to the WorkspaceIO port across engine and shared
*/
import { test, expect, beforeEach, afterEach, vi } from "vitest";
import fs from "node:fs/promises";
import { existsSync } from "node:fs";
import path from "node:path";
import os from "node:os";
const hoisted = vi.hoisted(() => ({
mockExecuteKernelPipeline: vi.fn(),
mockExecuteKernelCommand: vi.fn(),
mockReadMissionManifest: vi.fn(),
mockResolveMissionDir: vi.fn(),
mockRunOperation: vi.fn(),
mockCheckDifferentKindOperation: vi.fn(),
mockCommitBordbuchProjections: vi.fn(),
mockComputeBuildInputHash: vi.fn(),
}));
function makeInterceptedIo(): ReturnType["io"] {
const baseIo = createDefaultIO().io;
return {
...baseIo,
exec: vi.fn(
async (
command: string,
args: string[],
opts?: { cwd?: string; timeoutMs?: number; env?: Record },
) => {
if (command === "pnpm" && args.join(" ") === "exec astro build") {
return { exitCode: 0, stdout: "10 page(s)", stderr: "" };
}
return baseIo.exec(command, args, opts);
},
),
};
}
vi.mock("@warpgogol/werkstatt-engine/fingerprint", () => ({
byteHashFile: vi.fn().mockResolvedValue("sha256:mocked"),
}));
vi.mock("@warpgogol/werkstatt-engine/kernel", async (importOriginal) => {
const original = await importOriginal();
return {
...original,
executeKernelPipeline: hoisted.mockExecuteKernelPipeline,
executeKernelCommand: hoisted.mockExecuteKernelCommand,
};
});
vi.mock("../../kernel/cache/cache-layer.ts", () => ({
createCacheLayer: vi.fn().mockResolvedValue({
clear: vi.fn(),
close: vi.fn(),
status: vi.fn().mockResolvedValue({ entries: 0 }),
}),
}));
vi.mock("../../kernel/runtime/execute-pipeline.ts", async (importOriginal) => {
const original =
await importOriginal();
return {
...original,
clearPipelineCacheHits: vi.fn(),
};
});
vi.mock("../mission-io.ts", () => ({
readMissionManifest: hoisted.mockReadMissionManifest,
writeMissionManifest: vi.fn(),
resolveMissionDir: hoisted.mockResolveMissionDir,
}));
vi.mock("../mission-git-commit.ts", () => ({
isWorkpieceDirty: vi.fn().mockReturnValue({ dirty: false, fileCount: 0 }),
investigateUntrackedFiles: vi.fn().mockReturnValue([]),
commitWorkpieceIfDirty: vi.fn().mockResolvedValue(false),
commitCacheCloneIfDirty: vi.fn().mockResolvedValue(false),
cacheCloneCommit: vi.fn(),
}));
vi.mock("../../journal/runner.ts", () => ({
runOperation: hoisted.mockRunOperation,
}));
vi.mock("../../journal/index.ts", () => ({
checkDifferentKindOperation: hoisted.mockCheckDifferentKindOperation,
}));
vi.mock("../../bordbuch/bordbuch-commit.ts", () => ({
commitBordbuchProjections: hoisted.mockCommitBordbuchProjections,
}));
vi.mock("../../handoff/build-pipeline-helpers.ts", () => ({
runPipelinePhase: vi.fn(),
computeBuildInputHash: hoisted.mockComputeBuildInputHash,
writePreliminaryBuildIdentity: vi.fn(),
cleanupPreliminaryBuildIdentity: vi.fn(),
}));
vi.mock("../snapshot-auto-regen.ts", () => ({
orchestrateSnap01Recovery: vi.fn(),
bootstrapCommittedSnapshot: vi.fn().mockResolvedValue({ committed: true }),
SNAPSHOT_FILENAME: "behavior.snapshot.generated.yaml",
}));
vi.mock("../actor-identity.ts", () => ({
resolveActor: vi.fn().mockReturnValue({ kind: "human", id: "test-operator" }),
}));
vi.mock("../../sternsystem/registry-io.ts", () => ({
resolveCacheClonePath: vi.fn().mockReturnValue("/tmp/mock-cache-clone"),
readSystemConfig: vi.fn(),
}));
vi.mock("../cache-clone-gitignore.ts", () => ({
restoreCacheCloneGitignore: vi.fn(),
untrackForbiddenGeneratedFiles: vi.fn(),
CACHE_CLONE_GENERATED_PATTERNS: [],
}));
vi.mock("../workpiece-config-presence-check.ts", () => ({
checkWorkpieceConfigPresence: vi.fn().mockResolvedValue({ allPresent: true, missing: [] }),
}));
import { runMissionValidate } from "../mission-materialization-commands.ts";
import { createDefaultIO } from "@warpgogol/werkstatt-shared/kernel/workspace-io";
const baseManifest = {
id: "test-mission",
systemId: "test-system",
state: "open",
materializedAt: "2026-01-01T00:00:00Z",
brief: "test",
createdAt: "2026-01-01T00:00:00Z",
};
function makeContext(tmpDir: string) {
return {
io: makeInterceptedIo(),
workspaceRoot: tmpDir,
logger: {
info: () => {},
warn: () => {},
error: () => {},
debug: () => {},
},
} as any;
}
let tmpDir: string;
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "test-rfc1086-"));
const missionDir = path.join(tmpDir, "missions", "test-mission");
const evidenceDir = path.join(missionDir, "evidence");
const workpieceDir = path.join(missionDir, "workpiece");
await fs.mkdir(evidenceDir, { recursive: true });
await fs.mkdir(workpieceDir, { recursive: true });
hoisted.mockExecuteKernelPipeline.mockReset();
hoisted.mockExecuteKernelCommand.mockReset();
hoisted.mockReadMissionManifest.mockReset();
hoisted.mockResolveMissionDir.mockReset();
hoisted.mockRunOperation.mockReset();
hoisted.mockCheckDifferentKindOperation.mockReset();
hoisted.mockCommitBordbuchProjections.mockReset();
hoisted.mockComputeBuildInputHash.mockReset();
hoisted.mockReadMissionManifest.mockResolvedValue(baseManifest);
hoisted.mockResolveMissionDir.mockResolvedValue(missionDir);
hoisted.mockCheckDifferentKindOperation.mockResolvedValue({ blocked: false });
hoisted.mockCommitBordbuchProjections.mockResolvedValue({ committed: false, filesCommitted: [] });
hoisted.mockRunOperation.mockResolvedValue({ completed: true });
hoisted.mockExecuteKernelPipeline.mockResolvedValue({
ok: true,
steps: [],
timing: { failedStep: null },
});
hoisted.mockComputeBuildInputHash.mockResolvedValue({ buildInputHash: "sha256:mismatch" });
});
afterEach(async () => {
await fs.rm(tmpDir, { recursive: true, force: true });
});
function makePreviousReport(
overrides: Partial<{
missionId: string;
contractFullPassed: boolean;
buildSucceeded: boolean;
failedSteps: Array<{ name: string; exitCode: number }>;
routeCount: number;
sitemapHash: string;
}> = {},
): string {
const report = {
schemaVersion: "1.0.0",
missionId: overrides.missionId ?? "test-mission",
contractFull: {
passed: overrides.contractFullPassed ?? true,
validators: [],
},
build: {
succeeded: overrides.buildSucceeded ?? true,
routeCount: overrides.routeCount ?? 10,
sitemapHash: overrides.sitemapHash ?? "sha256:abc123",
failedSteps: overrides.failedSteps ?? [],
},
distributionReused: false,
fullBuildRan: true,
validatedAt: "2026-09-14T00:00:00Z",
};
return JSON.stringify(report, null, 2) + "\n";
}
const allPassedReport = makePreviousReport();
const buildCheckFailedReport = makePreviousReport({
contractFullPassed: false,
buildSucceeded: false,
failedSteps: [{ name: "build.check", exitCode: 1 }],
});
const buildPrepareFailedReport = makePreviousReport({
contractFullPassed: false,
buildSucceeded: false,
failedSteps: [{ name: "build.prepare", exitCode: 1 }],
});
test("AC-4: --fast and --force together returns exit code 1 with error message", async () => {
const result = await runMissionValidate(
{ argv: [], flags: { mission: "test-mission", fast: true, force: true } } as any,
makeContext(tmpDir),
);
expect(result.exitCode, "exit code must be 1 for mutually exclusive flags").toBe(1);
expect(result.summary, "summary must mention mutual exclusivity").toContain("mutually exclusive");
});
test("AC-5: --fast with all-passed previous report returns exit 0 and does not run steps", async () => {
const missionDir = path.join(tmpDir, "missions", "test-mission");
const evidenceDir = path.join(missionDir, "evidence");
await fs.writeFile(path.join(evidenceDir, "validation-report.json"), allPassedReport);
const result = await runMissionValidate(
{ argv: [], flags: { mission: "test-mission", fast: true } } as any,
makeContext(tmpDir),
);
expect(result.exitCode ?? 0, "exit code must be 0 when all phases passed").toBe(0);
expect(result.summary, "summary must mention skip").toContain("skipped");
expect(
hoisted.mockRunOperation,
"runOperation must NOT be called when all phases passed",
).not.toHaveBeenCalled();
});
test("AC-5: --fast with all-passed previous report does NOT overwrite validation-report.json", async () => {
const missionDir = path.join(tmpDir, "missions", "test-mission");
const evidenceDir = path.join(missionDir, "evidence");
await fs.writeFile(path.join(evidenceDir, "validation-report.json"), allPassedReport);
await runMissionValidate(
{ argv: [], flags: { mission: "test-mission", fast: true } } as any,
makeContext(tmpDir),
);
const content = await fs.readFile(path.join(evidenceDir, "validation-report.json"), "utf8");
const report = JSON.parse(content);
expect(report.validatedAt, "validatedAt must be unchanged — report must NOT be overwritten").toBe(
"2026-09-14T00:00:00Z",
);
});
test("AC-2 + AC-3: --fast with no previous report falls back to full run and warns", async () => {
const warnCalls: string[] = [];
const ctx = {
io: makeInterceptedIo(),
workspaceRoot: tmpDir,
logger: {
info: () => {},
warn: (msg: string) => {
warnCalls.push(msg);
},
error: () => {},
debug: () => {},
},
} as any;
await runMissionValidate(
{ argv: [], flags: { mission: "test-mission", fast: true } } as any,
ctx,
);
expect(
warnCalls.some((m) => m.includes("No previous validation report")),
"must warn about missing previous report",
).toBe(true);
expect(
hoisted.mockRunOperation,
"runOperation must be called for full run when no previous report",
).toHaveBeenCalledTimes(1);
});
test("AC-8 + AC-9: --fast with corrupt previous report falls back to full run and warns", async () => {
const missionDir = path.join(tmpDir, "missions", "test-mission");
const evidenceDir = path.join(missionDir, "evidence");
await fs.writeFile(path.join(evidenceDir, "validation-report.json"), "{corrupt json!!!");
const warnCalls: string[] = [];
const ctx = {
io: makeInterceptedIo(),
workspaceRoot: tmpDir,
logger: {
info: () => {},
warn: (msg: string) => {
warnCalls.push(msg);
},
error: () => {},
debug: () => {},
},
} as any;
await runMissionValidate(
{ argv: [], flags: { mission: "test-mission", fast: true } } as any,
ctx,
);
expect(
warnCalls.some((m) => m.includes("No previous validation report")),
"must warn about corrupt (unreadable) previous report",
).toBe(true);
expect(
hoisted.mockRunOperation,
"runOperation must be called for full run when previous report is corrupt",
).toHaveBeenCalledTimes(1);
});
test("AC-10 + AC-11: --fast with mismatched missionId falls back to full run and warns", async () => {
const missionDir = path.join(tmpDir, "missions", "test-mission");
const evidenceDir = path.join(missionDir, "evidence");
await fs.writeFile(
path.join(evidenceDir, "validation-report.json"),
makePreviousReport({ missionId: "different-mission" }),
);
const warnCalls: string[] = [];
const ctx = {
io: makeInterceptedIo(),
workspaceRoot: tmpDir,
logger: {
info: () => {},
warn: (msg: string) => {
warnCalls.push(msg);
},
error: () => {},
debug: () => {},
},
} as any;
await runMissionValidate(
{ argv: [], flags: { mission: "test-mission", fast: true } } as any,
ctx,
);
expect(
warnCalls.some((m) => m.includes("different mission")),
"must warn about missionId mismatch",
).toBe(true);
expect(
hoisted.mockRunOperation,
"runOperation must be called for full run when missionId mismatches",
).toHaveBeenCalledTimes(1);
});
test("AC-6: validateCtx.fast is true when --fast is passed and previous report exists", async () => {
const missionDir = path.join(tmpDir, "missions", "test-mission");
const evidenceDir = path.join(missionDir, "evidence");
await fs.writeFile(path.join(evidenceDir, "validation-report.json"), buildCheckFailedReport);
await runMissionValidate(
{ argv: [], flags: { mission: "test-mission", fast: true } } as any,
makeContext(tmpDir),
);
expect(hoisted.mockRunOperation, "runOperation must be called").toHaveBeenCalledTimes(1);
const validateCtx = hoisted.mockRunOperation.mock.calls[0][2];
expect(validateCtx.fast, "validateCtx.fast must be true when --fast is set").toBe(true);
});
test("AC-7: validateCtx.previousReport is populated from the previous report", async () => {
const missionDir = path.join(tmpDir, "missions", "test-mission");
const evidenceDir = path.join(missionDir, "evidence");
await fs.writeFile(path.join(evidenceDir, "validation-report.json"), buildCheckFailedReport);
await runMissionValidate(
{ argv: [], flags: { mission: "test-mission", fast: true } } as any,
makeContext(tmpDir),
);
const validateCtx = hoisted.mockRunOperation.mock.calls[0][2];
expect(
validateCtx.previousReport,
"validateCtx.previousReport must be populated from the previous report",
).not.toBeNull();
expect(
validateCtx.previousReport.missionId,
"previousReport.missionId must match the report content",
).toBe("test-mission");
expect(
validateCtx.previousReport.build.failedSteps.length,
"previousReport must contain the failed steps from the report",
).toBe(1);
});
test("AC-1: --fast with build-check failed skips build-prepare but runs build-check and downstream (cascade)", async () => {
const missionDir = path.join(tmpDir, "missions", "test-mission");
const evidenceDir = path.join(missionDir, "evidence");
await fs.writeFile(path.join(evidenceDir, "validation-report.json"), buildCheckFailedReport);
// Capture the steps built by buildValidateSteps
let builtSteps: any[] = [];
hoisted.mockRunOperation.mockImplementation(async (_journalPath: string, op: any, ctx: any) => {
builtSteps = op.steps;
// Simulate running all steps to check cascade behavior
for (const step of builtSteps) {
if (step.run) await step.run(ctx);
}
return { completed: true };
});
await runMissionValidate(
{ argv: [], flags: { mission: "test-mission", fast: true } } as any,
makeContext(tmpDir),
);
const validateCtx = hoisted.mockRunOperation.mock.calls[0][2];
// build-prepare should be skipped (contractFull.passed was false in buildCheckFailedReport? No — contractFullPassed: false means prepare failed)
// Actually in buildCheckFailedReport, contractFullPassed is false — so build-prepare will NOT be skipped
// Let me fix: buildCheckFailedReport has contractFullPassed: false, which means build.prepare failed
// So build-prepare will re-run, and cascade will propagate to all downstream
expect(validateCtx.cascadeRerun, "cascadeRerun must be true after build-prepare re-runs").toBe(
true,
);
});
test("AC-1: --fast with only build-check failed skips build-prepare, re-runs build-check, cascades to astro-build and build-post", async () => {
const missionDir = path.join(tmpDir, "missions", "test-mission");
const evidenceDir = path.join(missionDir, "evidence");
// build.prepare passed (contractFullPassed: true), build.check failed (failedSteps non-empty)
await fs.writeFile(
path.join(evidenceDir, "validation-report.json"),
makePreviousReport({
contractFullPassed: true,
buildSucceeded: false,
failedSteps: [{ name: "build.check", exitCode: 1 }],
}),
);
const pipelineCallNames: string[] = [];
hoisted.mockExecuteKernelPipeline.mockImplementation(async (args: any) => {
pipelineCallNames.push(args.pipelineName);
return { ok: true, steps: [], timing: { failedStep: null } };
});
hoisted.mockRunOperation.mockImplementation(async (_journalPath: string, op: any, ctx: any) => {
for (const step of op.steps) {
if (step.run) await step.run(ctx);
}
return { completed: true };
});
await runMissionValidate(
{ argv: [], flags: { mission: "test-mission", fast: true } } as any,
makeContext(tmpDir),
);
// build.prepare should be skipped (contractFullPassed: true)
// build.check should re-run (failedSteps non-empty)
// astro-build should re-run (cascade from build-check)
// build-post should re-run (cascade from astro-build)
expect(
pipelineCallNames,
"build.prepare must be skipped, build.check + build.post must re-run (cascade)",
).toEqual(["build.check", "build.post"]);
});
test("AC-1: --fast with build-prepare failed re-runs all phases (cascade from prepare)", async () => {
const missionDir = path.join(tmpDir, "missions", "test-mission");
const evidenceDir = path.join(missionDir, "evidence");
await fs.writeFile(path.join(evidenceDir, "validation-report.json"), buildPrepareFailedReport);
const pipelineCallNames: string[] = [];
hoisted.mockExecuteKernelPipeline.mockImplementation(async (args: any) => {
pipelineCallNames.push(args.pipelineName);
return { ok: true, steps: [], timing: { failedStep: null } };
});
hoisted.mockRunOperation.mockImplementation(async (_journalPath: string, op: any, ctx: any) => {
for (const step of op.steps) {
if (step.run) await step.run(ctx);
}
return { completed: true };
});
await runMissionValidate(
{ argv: [], flags: { mission: "test-mission", fast: true } } as any,
makeContext(tmpDir),
);
// build.prepare re-runs (contractFullPassed: false), cascade to all
expect(
pipelineCallNames,
"all phases must re-run when build-prepare failed (cascade from prepare)",
).toEqual(["build.prepare", "build.check", "build.post"]);
});
test("RFC-1086: --fast does NOT delete validation-report.json before reading it", async () => {
const missionDir = path.join(tmpDir, "missions", "test-mission");
const evidenceDir = path.join(missionDir, "evidence");
await fs.writeFile(path.join(evidenceDir, "validation-report.json"), buildCheckFailedReport);
await runMissionValidate(
{ argv: [], flags: { mission: "test-mission", fast: true } } as any,
makeContext(tmpDir),
);
// The previous report must still exist when --fast is active (deletion is guarded)
// It will be overwritten by the new run, but it must not have been deleted BEFORE the run
expect(
existsSync(path.join(evidenceDir, "validation-report.json")),
"validation-report.json must not be deleted before --fast reads it",
).toBe(true);
});
test("RFC-1086: without --fast, validation-report.json IS deleted (RFC-1020 behavior preserved)", async () => {
const missionDir = path.join(tmpDir, "missions", "test-mission");
const evidenceDir = path.join(missionDir, "evidence");
await fs.writeFile(path.join(evidenceDir, "validation-report.json"), '{"stale":true}');
await runMissionValidate(
{ argv: [], flags: { mission: "test-mission" } } as any,
makeContext(tmpDir),
);
// Without --fast, the stale report is deleted (RFC-1020 behavior)
// runOperation mock returns { completed: true } without writing a new report
expect(
existsSync(path.join(evidenceDir, "validation-report.json")),
"validation-report.json must be deleted without --fast (RFC-1020 preserved)",
).toBe(false);
});