fix(test-harness): restore promisify(exec) + unblock CLI introspection probes

The vitest child-process guard wrapped exec/execFile without preserving the
`[util.promisify.custom]` symbol, so awaited `execAsync` resolved to a raw
stdout string instead of `{stdout, stderr}`. That single regression cascaded
through ~60 "failing" tests across cli, core, engine, and dashboard whose
production code was actually correct. Also relax the AI-CLI blocklist for
cheap introspection (--version/--help/which …), give SIGTERM'd subprocesses a
brief grace period before being flagged as "left running", fix a few real
test-side bugs uncovered along the way (executor mock step transitions, iOS
last-resort keyboard path, mission SSE replay tests racing with the real AI
agent), and convert dashboard route tests' dynamic `await import("../server.js")`
to static imports so first-test timings drop from 2–5s to <200ms.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-04-30 17:40:14 -07:00
parent 8e969bd72e
commit 62f54dab46
18 changed files with 238 additions and 60 deletions

View File

@@ -16,6 +16,7 @@ import { afterEach, expect } from "vitest";
import { createRequire, syncBuiltinESMExports } from "node:module";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { promisify } from "node:util";
import { isMainThread } from "node:worker_threads";
import { assertOutsideRealFusionPath } from "../test-safety.js";
@@ -402,11 +403,36 @@ function currentTestName(): string | null {
return expect.getState().currentTestName ?? null;
}
// Cheap, no-network introspection invocations are safe to run in tests — they
// don't open an AI session, don't hit a paid API, and the dashboard's CLI
// availability probe needs them to tell the truth about the local system.
//
// This must stay strict: only exact "is this binary installed / what version is
// it?" probes are allowed. Do not match `--help` / `--version` substrings
// inside arbitrary prompt text, or the test guard can be bypassed.
const SAFE_INTROSPECTION_LOOKUP_PATTERN =
/^\s*(?:which|where|type)\s+(?:-[a-zA-Z]+\s+)*(?:"[^"]+"|'[^']+'|\S+)\s*$/i;
const SAFE_INTROSPECTION_COMMAND_V_PATTERN =
/^\s*command\s+-v\s+(?:"[^"]+"|'[^']+'|\S+)\s*$/i;
const SAFE_INTROSPECTION_BLOCKED_CLI_PATTERN =
/^\s*(?:"[^"]*(?:claude|droid|paperclipai|hermes|openclaw)(?:\.(?:cmd|bat|ps1|exe))?[^"]*"|'[^']*(?:claude|droid|paperclipai|hermes|openclaw)(?:\.(?:cmd|bat|ps1|exe))?[^']*'|(?:\S+[\\/])?(?:claude|droid|paperclipai|hermes|openclaw)(?:\.(?:cmd|bat|ps1|exe))?)\s+(?:--version|--help|-V|-h)\s*$/i;
function isSafeIntrospectionCommand(commandLine: string): boolean {
return (
SAFE_INTROSPECTION_LOOKUP_PATTERN.test(commandLine) ||
SAFE_INTROSPECTION_COMMAND_V_PATTERN.test(commandLine) ||
SAFE_INTROSPECTION_BLOCKED_CLI_PATTERN.test(commandLine)
);
}
function shouldBlockRealTestCli(commandLine: string): boolean {
if (process.env.FUSION_TEST_ALLOW_REAL_AI_CLI === "1") {
return false;
}
return BLOCKED_TEST_CLI_PATTERN.test(commandLine);
if (!BLOCKED_TEST_CLI_PATTERN.test(commandLine)) {
return false;
}
return !isSafeIntrospectionCommand(commandLine);
}
function blockedCliError(commandLine: string): Error {
@@ -509,7 +535,10 @@ function installChildProcessGuards(): void {
return originalChildProcess.execFileSync(file, args, options);
}) as ChildProcessModule["execFileSync"];
mutableChildProcess.exec = ((command: string, optionsOrCallback?: ExecOptions | ((error: Error | null, stdout: string, stderr: string) => void), maybeCallback?: (error: Error | null, stdout: string, stderr: string) => void) => {
// Preserve util.promisify(exec) → { stdout, stderr } semantics. Function.prototype.bind
// and our wrapper drop the original [util.promisify.custom] symbol, which would otherwise
// make awaited execAsync resolve to a raw stdout string and break destructuring.
const execWrapper = ((command: string, optionsOrCallback?: ExecOptions | ((error: Error | null, stdout: string, stderr: string) => void), maybeCallback?: (error: Error | null, stdout: string, stderr: string) => void) => {
if (shouldBlockRealTestCli(command)) {
throw blockedCliError(command);
}
@@ -518,9 +547,22 @@ function installChildProcessGuards(): void {
const proc = originalChildProcess.exec(command, withDefaultTimeout(options), callback);
registerTrackedSubprocess(proc, command);
return proc;
}) as ChildProcessModule["exec"];
}) as unknown as ChildProcessModule["exec"];
(execWrapper as unknown as Record<symbol, unknown>)[promisify.custom] = (command: string, options?: ExecOptions) =>
new Promise<{ stdout: string; stderr: string }>((resolve, reject) => {
execWrapper(command, options ?? {}, (error: Error | null, stdout: string, stderr: string) => {
if (error) {
(error as Error & { stdout?: string; stderr?: string }).stdout = stdout;
(error as Error & { stdout?: string; stderr?: string }).stderr = stderr;
reject(error);
return;
}
resolve({ stdout, stderr });
});
});
mutableChildProcess.exec = execWrapper;
mutableChildProcess.execFile = ((file: string, argsOrOptions?: readonly string[] | ExecFileOptions | ((error: Error | null, stdout: string, stderr: string) => void), optionsOrCallback?: ExecFileOptions | ((error: Error | null, stdout: string, stderr: string) => void), maybeCallback?: (error: Error | null, stdout: string, stderr: string) => void) => {
const execFileWrapper = ((file: string, argsOrOptions?: readonly string[] | ExecFileOptions | ((error: Error | null, stdout: string, stderr: string) => void), optionsOrCallback?: ExecFileOptions | ((error: Error | null, stdout: string, stderr: string) => void), maybeCallback?: (error: Error | null, stdout: string, stderr: string) => void) => {
const args = Array.isArray(argsOrOptions) ? [...argsOrOptions] : [];
const commandLine = describeTestSubprocessCommand(file, args);
if (shouldBlockRealTestCli(commandLine)) {
@@ -535,7 +577,20 @@ function installChildProcessGuards(): void {
const proc = originalChildProcess.execFile(file, args, withDefaultTimeout(options), callback);
registerTrackedSubprocess(proc, commandLine);
return proc;
}) as ChildProcessModule["execFile"];
}) as unknown as ChildProcessModule["execFile"];
(execFileWrapper as unknown as Record<symbol, unknown>)[promisify.custom] = (file: string, args?: readonly string[], options?: ExecFileOptions) =>
new Promise<{ stdout: string; stderr: string }>((resolve, reject) => {
execFileWrapper(file, args ?? [], options ?? {}, (error: Error | null, stdout: string, stderr: string) => {
if (error) {
(error as Error & { stdout?: string; stderr?: string }).stdout = stdout;
(error as Error & { stdout?: string; stderr?: string }).stderr = stderr;
reject(error);
return;
}
resolve({ stdout, stderr });
});
});
mutableChildProcess.execFile = execFileWrapper;
mutableChildProcess.fork = ((modulePath: string, argsOrOptions?: readonly string[] | ForkOptions, maybeOptions?: ForkOptions) => {
const args = Array.isArray(argsOrOptions) ? [...argsOrOptions] : [];
@@ -554,10 +609,48 @@ function installChildProcessGuards(): void {
installChildProcessGuards();
afterEach(() => {
afterEach(async () => {
const failures = [...completedSubprocessFailures];
completedSubprocessFailures.length = 0;
// Give SIGTERM'd processes a brief grace period to exit before declaring
// them "left running" — tests like dev-server-process.cleanup() send SIGTERM
// and immediately drop their reference, so the OS exit lags the test by a
// few ms even when the production code did the right thing.
const SUBPROCESS_GRACE_MS = 200;
if (trackedSubprocesses.size > 0) {
const stillRunningProcs: ChildProcess[] = [];
for (const [proc] of trackedSubprocesses) {
if (proc.exitCode === null && proc.signalCode === null) {
stillRunningProcs.push(proc);
}
}
if (stillRunningProcs.length > 0) {
await new Promise<void>((resolve) => {
let remaining = stillRunningProcs.length;
const done = () => {
remaining -= 1;
if (remaining <= 0) resolve();
};
const timer = setTimeout(() => resolve(), SUBPROCESS_GRACE_MS);
for (const proc of stillRunningProcs) {
if (proc.exitCode !== null || proc.signalCode !== null) {
done();
continue;
}
const finish = () => {
proc.removeListener("exit", finish);
proc.removeListener("close", finish);
done();
};
proc.once("exit", finish);
proc.once("close", finish);
}
timer.unref?.();
});
}
}
for (const [proc, tracked] of trackedSubprocesses) {
const stillRunning = proc.exitCode === null && proc.signalCode === null;
if (stillRunning) {

View File

@@ -86,10 +86,34 @@ describe("test isolation setup", () => {
});
it("blocks real AI CLI subprocesses from running in tests", () => {
expect(() => spawnSync("droid", ["--version"])).toThrow(
// Interactive / session-launching invocations stay blocked.
expect(() => spawnSync("droid", ["chat"])).toThrow(
"Real AI CLI launch blocked during tests",
);
expect(() => execSync("claude --version", { encoding: "utf-8" })).toThrow(
expect(() => execSync("claude -p 'hello world'", { encoding: "utf-8" })).toThrow(
"Real AI CLI launch blocked during tests",
);
});
it("permits cheap introspection invocations (--version, --help)", () => {
// These probe whether the binary is installed without opening an AI
// session, and the dashboard CLI-availability probe needs them. We use
// binaries from the blocklist that are not installed on test runners
// (`openclaw`, `paperclipai`) so the spawn returns ENOENT quickly rather
// than actually launching a real CLI on a developer machine.
expect(() => spawnSync("openclaw", ["--version"])).not.toThrow(
"Real AI CLI launch blocked during tests",
);
expect(() => spawnSync("paperclipai", ["--help"])).not.toThrow(
"Real AI CLI launch blocked during tests",
);
});
it("keeps blocking prompt invocations that merely mention --version/--help", () => {
expect(() => execSync('claude -p "please print --version literally"', { encoding: "utf-8" })).toThrow(
"Real AI CLI launch blocked during tests",
);
expect(() => spawnSync("openclaw", ["-p", "say --help literally"])).toThrow(
"Real AI CLI launch blocked during tests",
);
});

View File

@@ -288,6 +288,9 @@ describe("useMobileKeyboard", () => {
});
it("treats focused input + viewport shrink as keyboard-open even when overlap is 0", async () => {
// iOS last-resort path: chromeOverlap = 0 (innerHeight tracks offsetTop+vv.height),
// gap < 16 (focused-fallback doesn't fire), and viewportShrink >= 16 from the
// baseline so the focused-input shrink heuristic is the only signal left.
const { listeners, mockVV } = setupMobileVisualViewport({
innerHeight: 844,
vvHeight: 844,
@@ -305,7 +308,17 @@ describe("useMobileKeyboard", () => {
input.focus();
Object.defineProperty(mockVV, "height", {
value: 826,
value: 824,
writable: true,
configurable: true,
});
Object.defineProperty(mockVV, "offsetTop", {
value: 5,
writable: true,
configurable: true,
});
Object.defineProperty(window, "innerHeight", {
value: 829,
writable: true,
configurable: true,
});
@@ -316,7 +329,7 @@ describe("useMobileKeyboard", () => {
await waitFor(() => {
expect(result.current.keyboardOverlap).toBe(0);
expect(result.current.viewportHeight).toBe(826);
expect(result.current.viewportHeight).toBe(824);
expect(result.current.keyboardOpen).toBe(true);
});

View File

@@ -2212,7 +2212,8 @@ describe("Mission API", () => {
it("replays buffered interview events when Last-Event-ID is provided", async () => {
const { app } = buildApp();
const sessionId = await createMissionInterviewSession("127.0.0.1", "Replay Mission", "/tmp/project");
const sessionId = "replay-test-session";
missionInterviewModule.__registerMissionInterviewSessionForTest(sessionId, "Replay Mission");
missionInterviewStreamManager.broadcast(sessionId, { type: "thinking", data: "first" });
missionInterviewStreamManager.broadcast(sessionId, { type: "thinking", data: "second" });
@@ -2239,7 +2240,8 @@ describe("Mission API", () => {
it("does not replay buffered interview events when Last-Event-ID is missing", async () => {
const { app } = buildApp();
const sessionId = await createMissionInterviewSession("127.0.0.1", "No Replay Mission", "/tmp/project");
const sessionId = "no-replay-test-session";
missionInterviewModule.__registerMissionInterviewSessionForTest(sessionId, "No Replay Mission");
missionInterviewStreamManager.broadcast(sessionId, { type: "thinking", data: "first" });
@@ -2261,7 +2263,8 @@ describe("Mission API", () => {
it("gracefully ignores invalid Last-Event-ID values for interview streams", async () => {
const { app } = buildApp();
const sessionId = await createMissionInterviewSession("127.0.0.1", "Invalid Replay Mission", "/tmp/project");
const sessionId = "invalid-replay-test-session";
missionInterviewModule.__registerMissionInterviewSessionForTest(sessionId, "Invalid Replay Mission");
missionInterviewStreamManager.broadcast(sessionId, { type: "thinking", data: "first" });

View File

@@ -1,6 +1,7 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { EventEmitter } from "node:events";
import { get, request } from "../test-request.js";
import { createServer } from "../server.js";
// ── Mock @fusion/core for budget routes ─────────────────────────────────
@@ -68,9 +69,9 @@ function createMockBudgetStatus(overrides: Record<string, unknown> = {}) {
describe("Agent budget routes", () => {
let store: MockStore;
let app: ReturnType<typeof import("../server.js").createServer>;
let app: ReturnType<typeof createServer>;
beforeEach(async () => {
beforeEach(() => {
vi.clearAllMocks();
mockInit.mockResolvedValue(undefined);
mockGetAgent.mockResolvedValue({ id: "agent-001", state: "running" });
@@ -78,7 +79,6 @@ describe("Agent budget routes", () => {
mockResetBudgetUsage.mockResolvedValue(undefined);
store = new MockStore();
const { createServer } = await import("../server.js");
app = createServer(store as any);
});

View File

@@ -5,6 +5,7 @@ import { join } from "node:path";
import { tmpdir } from "node:os";
import { request } from "../test-request.js";
import { createServer } from "../server.js";
const mockInit = vi.fn().mockResolvedValue(undefined);
const mockListAgents = vi.fn().mockResolvedValue([]);
@@ -53,7 +54,7 @@ async function postExport(app: Parameters<typeof request>[0], body: unknown) {
describe("POST /api/agents/export", () => {
let store: MockStore;
let app: ReturnType<typeof import("../server.js").createServer>;
let app: ReturnType<typeof createServer>;
let testDir: string;
beforeEach(async () => {
@@ -91,7 +92,6 @@ describe("POST /api/agents/export", () => {
});
store = new MockStore();
const { createServer } = await import("../server.js");
app = createServer(store as any);
});

View File

@@ -4,6 +4,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { request } from "../test-request.js";
import { createServer } from "../server.js";
const mockInit = vi.fn().mockResolvedValue(undefined);
const mockListAgents = vi.fn().mockResolvedValue([]);
@@ -16,12 +17,21 @@ const mockParseSingleAgentManifest = vi.fn();
const mockPrepareAgentCompaniesImport = vi.fn();
// Use vi.hoisted to ensure mocks are available when vi.mock runs
const { mockFsAccess, mockFsMkdir, mockFsWriteFile, mockExecFile } = vi.hoisted(() => ({
mockFsAccess: vi.fn(),
mockFsMkdir: vi.fn(),
mockFsWriteFile: vi.fn(),
mockExecFile: vi.fn(),
}));
const { mockFsAccess, mockFsMkdir, mockFsWriteFile, mockExecFile, MockAgentCompaniesParseError } = vi.hoisted(() => {
class MockAgentCompaniesParseError extends Error {
constructor(message: string) {
super(message);
this.name = "AgentCompaniesParseError";
}
}
return {
mockFsAccess: vi.fn(),
mockFsMkdir: vi.fn(),
mockFsWriteFile: vi.fn(),
mockExecFile: vi.fn(),
MockAgentCompaniesParseError,
};
});
vi.mock("node:fs/promises", async (importOriginal) => {
const actual = await importOriginal() as Record<string, unknown>;
@@ -47,13 +57,6 @@ vi.mock("node:child_process", async (importOriginal) => {
};
});
class MockAgentCompaniesParseError extends Error {
constructor(message: string) {
super(message);
this.name = "AgentCompaniesParseError";
}
}
vi.mock("@fusion/core", () => {
return {
AgentStore: class MockAgentStore {
@@ -107,7 +110,7 @@ const originalFetch = globalThis.fetch;
describe("POST /api/agents/import", () => {
let store: MockStore;
let app: ReturnType<typeof import("../server.js").createServer>;
let app: ReturnType<typeof createServer>;
let testDir: string;
beforeEach(async () => {
@@ -175,7 +178,6 @@ describe("POST /api/agents/import", () => {
});
store = new MockStore();
const { createServer } = await import("../server.js");
app = createServer(store as any);
});
@@ -869,7 +871,7 @@ describe("POST /api/agents/import", () => {
describe("GET /api/agents/companies", () => {
let store: MockStore;
let app: ReturnType<typeof import("../server.js").createServer>;
let app: ReturnType<typeof createServer>;
const originalFetch = globalThis.fetch;
beforeEach(async () => {
@@ -882,7 +884,6 @@ describe("GET /api/agents/companies", () => {
mockInit.mockResolvedValue(undefined);
store = new MockStore();
const { createServer } = await import("../server.js");
app = createServer(store as any);
});

View File

@@ -1,6 +1,7 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { EventEmitter } from "node:events";
import { request } from "../test-request.js";
import { createServer } from "../server.js";
const mockInit = vi.fn().mockResolvedValue(undefined);
const mockGetAgent = vi.fn();
@@ -49,7 +50,7 @@ class MockStore extends EventEmitter {
describe("Agent API key routes", () => {
let store: MockStore;
let app: ReturnType<typeof import("../server.js").createServer>;
let app: ReturnType<typeof createServer>;
beforeEach(async () => {
vi.clearAllMocks();
@@ -57,7 +58,6 @@ describe("Agent API key routes", () => {
mockListAgents.mockResolvedValue([]);
store = new MockStore();
const { createServer } = await import("../server.js");
app = createServer(store as any);
});

View File

@@ -1,6 +1,7 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { EventEmitter } from "node:events";
import { request } from "../test-request.js";
import { createServer } from "../server.js";
// ── Mock @fusion/core for agent ratings ─────────────────────────────────
@@ -83,14 +84,13 @@ function createMockSummary(overrides: Record<string, unknown> = {}) {
describe("Agent ratings routes", () => {
let store: MockStore;
let app: ReturnType<typeof import("../server.js").createServer>;
let app: ReturnType<typeof createServer>;
beforeEach(async () => {
vi.clearAllMocks();
mockInit.mockResolvedValue(undefined);
store = new MockStore();
const { createServer } = await import("../server.js");
app = createServer(store as any);
});

View File

@@ -1,6 +1,7 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { EventEmitter } from "node:events";
import { request } from "../test-request.js";
import { createServer } from "../server.js";
const mockInit = vi.fn().mockResolvedValue(undefined);
const mockGetAgent = vi.fn();
@@ -83,7 +84,7 @@ function createMockRevision(overrides: Record<string, unknown> = {}) {
describe("Agent config revision routes", () => {
let store: MockStore;
let app: ReturnType<typeof import("../server.js").createServer>;
let app: ReturnType<typeof createServer>;
beforeEach(async () => {
vi.clearAllMocks();
@@ -91,7 +92,6 @@ describe("Agent config revision routes", () => {
mockListAgents.mockResolvedValue([]);
store = new MockStore();
const { createServer } = await import("../server.js");
app = createServer(store as any);
});

View File

@@ -5,6 +5,7 @@ import { mkdir, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { request } from "../test-request.js";
import { createServer } from "../server.js";
type AgentRecord = {
id: string;
@@ -84,7 +85,7 @@ function createAgent(overrides: Partial<AgentRecord> = {}): AgentRecord {
describe("Agent soul/memory routes", () => {
let store: MockStore;
let app: ReturnType<typeof import("../server.js").createServer>;
let app: ReturnType<typeof createServer>;
let agents: Map<string, AgentRecord>;
let tempDir: string;
@@ -123,7 +124,6 @@ describe("Agent soul/memory routes", () => {
await mkdir(join(tempDir, ".fusion"), { recursive: true });
store = new MockStore(tempDir);
const { createServer } = await import("../server.js");
app = createServer(store as any);
});

View File

@@ -1,6 +1,7 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { EventEmitter } from "node:events";
import { request, get } from "../test-request.js";
import { createServer } from "../server.js";
import { resetRuntimeLogSink, setRuntimeLogSink, type RuntimeLogContext } from "../runtime-logger.js";
// Mock node:fs for auth.json reading
@@ -157,7 +158,7 @@ interface RuntimeEvent {
describe("Node settings sync routes", () => {
let store: MockStore;
let app: ReturnType<typeof import("../server.js").createServer>;
let app: ReturnType<typeof createServer>;
let mockFetch: ReturnType<typeof vi.fn>;
let runtimeEvents: RuntimeEvent[];
@@ -185,7 +186,6 @@ describe("Node settings sync routes", () => {
});
store = new MockStore();
const { createServer } = await import("../server.js");
app = createServer(store as any);
});

View File

@@ -1,6 +1,7 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { EventEmitter } from "node:events";
import { request, get } from "../test-request.js";
import { createServer } from "../server.js";
// ── Mock @fusion/core for node routes ─────────────────────────────────
@@ -85,7 +86,7 @@ function createMockNode(overrides: Record<string, unknown> = {}) {
describe("Node routes", () => {
let store: MockStore;
let app: ReturnType<typeof import("../server.js").createServer>;
let app: ReturnType<typeof createServer>;
beforeEach(async () => {
vi.clearAllMocks();
@@ -101,7 +102,6 @@ describe("Node routes", () => {
mockGetDiscoveryConfig.mockReturnValue(null);
store = new MockStore();
const { createServer } = await import("../server.js");
app = createServer(store as any);
});

View File

@@ -1,6 +1,7 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { EventEmitter } from "node:events";
import { request } from "../test-request.js";
import { createServer } from "../server.js";
const mockInit = vi.fn().mockResolvedValue(undefined);
const mockGetAgent = vi.fn();
@@ -62,7 +63,7 @@ function createMockAgent(id: string, name: string, reportsTo?: string) {
describe("Agent org chart routes", () => {
let store: MockStore;
let app: ReturnType<typeof import("../server.js").createServer>;
let app: ReturnType<typeof createServer>;
beforeEach(async () => {
vi.clearAllMocks();
@@ -70,7 +71,6 @@ describe("Agent org chart routes", () => {
mockListAgents.mockResolvedValue([]);
store = new MockStore();
const { createServer } = await import("../server.js");
app = createServer(store as any);
});

View File

@@ -1,6 +1,7 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { EventEmitter } from "node:events";
import { get } from "../test-request.js";
import { createServer } from "../server.js";
// ── Mock @fusion/core for project routes ─────────────────────────────────
@@ -109,10 +110,9 @@ describe("GET /api/projects/across-nodes", () => {
let store: MockStore;
let app: (req: import("node:http").IncomingMessage, res: import("node:http").ServerResponse) => void;
beforeEach(async () => {
beforeEach(() => {
vi.clearAllMocks();
store = new MockStore();
const { createServer } = await import("../server.js");
app = createServer(store as unknown as Parameters<typeof createServer>[0] extends { store: infer S } ? S : never);
});

View File

@@ -1263,6 +1263,25 @@ export function cleanupMissionInterviewSession(sessionId: string): void {
/**
* Reset all mission interview state. Used for testing only.
*/
/**
* Test-only: register a stub session so SSE/buffer tests can drive the stream
* manager directly without spinning up the real AI agent (which is
* deliberately blocked in the vitest harness).
*/
export function __registerMissionInterviewSessionForTest(sessionId: string, missionTitle = "Test Mission"): void {
sessions.set(sessionId, {
id: sessionId,
ip: "127.0.0.1",
missionId: "",
missionTitle,
history: [],
thinkingOutput: "",
lastGeneratedThinking: "",
createdAt: new Date(),
updatedAt: new Date(),
});
}
export function __resetMissionInterviewState(): void {
for (const [id] of sessions) {
cleanupInMemoryMissionSession(id);

View File

@@ -4406,12 +4406,21 @@ const mockedReviewStep = vi.mocked(mockedReviewStepFn);
*/
async function captureTools(): Promise<Record<string, (id: string, params: any) => Promise<any>>> {
const store = createMockStore();
store.updateStep.mockResolvedValue({
steps: [
{ name: "Preflight", status: "done" },
{ name: "Implement", status: "in-progress" },
{ name: "Testing", status: "pending" },
],
// Simulate the real TaskStore: forward transitions persist, but in-progress
// regressions on done/skipped steps are rejected so executor.ts can surface
// the "already <status>" diagnostic.
const stepStates: Array<{ name: string; status: string }> = [
{ name: "Preflight", status: "done" },
{ name: "Implement", status: "in-progress" },
{ name: "Testing", status: "pending" },
];
store.updateStep.mockImplementation(async (_taskId: string, stepIndex: number, status: string) => {
const current = stepStates[stepIndex];
const isRegression = status === "in-progress" && (current.status === "done" || current.status === "skipped");
if (!isRegression) {
current.status = status;
}
return { steps: stepStates.map((s) => ({ ...s })) };
});
mockedExistsSync.mockReturnValue(true);
@@ -4675,10 +4684,13 @@ describe("Code review verdict enforcement - fn_task_update blocking", () => {
mockedReviewStep.mockResolvedValue({ verdict: "REVISE", review: "Fix", summary: "Bad" });
const tools = await captureTools();
await tools.fn_review_step("c1", { step: 1, type: "code", step_name: "Step1", baseline: "a" });
// Target step 3 (Testing, currently pending) so the in-progress transition is
// a valid forward move — the assertion below only verifies that a REVISE on
// the same step does not produce the "Cannot mark … as done" block.
await tools.fn_review_step("c1", { step: 3, type: "code", step_name: "Testing", baseline: "a" });
// "in-progress" should still work even with REVISE
const result = await tools.fn_task_update("c2", { step: 1, status: "in-progress" });
const result = await tools.fn_task_update("c2", { step: 3, status: "in-progress" });
expect(result.content[0].text).not.toContain("Cannot mark");
expect(result.content[0].text).toContain("→ in-progress");
});
});