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:
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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" });
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user