Two root causes for the failing timeout test: 1. Test config used `workspace_root` instead of `workspaces_root`, causing Zod validation to fail silently, so resolveTimeoutMs() fell back to the default 120s — the mock's 200ms close event always beat it. 2. Schema enforced `.int()` on claude_timeout_seconds, rejecting the test's 0.05s fractional value. Removed the integer constraint since sub-second timeouts are valid (and useful for testing). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
337 lines
11 KiB
TypeScript
337 lines
11 KiB
TypeScript
import {
|
|
describe,
|
|
it,
|
|
expect,
|
|
vi,
|
|
beforeAll,
|
|
afterAll,
|
|
beforeEach,
|
|
afterEach,
|
|
} from "vitest";
|
|
import * as fs from "fs";
|
|
import * as os from "os";
|
|
import * as path from "path";
|
|
|
|
/**
|
|
* Unit tests for the RunResult discriminated union returned by runAgent().
|
|
*
|
|
* cross-spawn is mocked so no real process is launched. Each test controls
|
|
* what the fake child process emits (stdout, exit code, error event) to cover
|
|
* all four RunResult status variants.
|
|
*/
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Configurable mock state — mutated per test via setMockBehavior()
|
|
// ---------------------------------------------------------------------------
|
|
interface MockBehavior {
|
|
/** stdout data to emit (default: valid JSON success payload) */
|
|
stdoutData?: string;
|
|
/** exit code the child closes with (default: 0) */
|
|
exitCode?: number;
|
|
/** if set, child emits an "error" event with this error object */
|
|
spawnError?: NodeJS.ErrnoException;
|
|
/** if true, close event is never emitted (simulates a hanging process) */
|
|
neverClose?: boolean;
|
|
/** delay in ms before emitting close (default: 0) */
|
|
closeDelayMs?: number;
|
|
}
|
|
|
|
let mockBehavior: MockBehavior = {};
|
|
|
|
function setMockBehavior(b: MockBehavior) {
|
|
mockBehavior = b;
|
|
}
|
|
|
|
vi.mock("cross-spawn", () => {
|
|
function makeMockChild() {
|
|
const listeners: Record<string, Array<(...a: unknown[]) => void>> = {};
|
|
|
|
const emit = (event: string, ...args: unknown[]) => {
|
|
for (const cb of listeners[event] ?? []) {
|
|
cb(...args);
|
|
}
|
|
};
|
|
|
|
const stdoutListeners: Record<string, Array<(...a: unknown[]) => void>> =
|
|
{};
|
|
|
|
const stdoutStream = {
|
|
setEncoding: () => {},
|
|
on: (event: string, cb: (...a: unknown[]) => void) => {
|
|
if (!stdoutListeners[event]) stdoutListeners[event] = [];
|
|
stdoutListeners[event].push(cb);
|
|
|
|
if (event === "data" && !mockBehavior.spawnError) {
|
|
const data =
|
|
mockBehavior.stdoutData ??
|
|
JSON.stringify({ result: "hello from agent" });
|
|
Promise.resolve().then(() => {
|
|
for (const handler of stdoutListeners["data"] ?? []) {
|
|
handler(data);
|
|
}
|
|
});
|
|
}
|
|
},
|
|
};
|
|
|
|
const child = {
|
|
stdout: stdoutStream,
|
|
stderr: {
|
|
setEncoding: () => {},
|
|
on: () => {},
|
|
},
|
|
stdin: { end: () => {} },
|
|
on: (event: string, cb: (...a: unknown[]) => void) => {
|
|
if (!listeners[event]) listeners[event] = [];
|
|
listeners[event].push(cb);
|
|
|
|
if (event === "error" && mockBehavior.spawnError) {
|
|
Promise.resolve().then(() => emit("error", mockBehavior.spawnError));
|
|
return;
|
|
}
|
|
|
|
if (event === "close" && !mockBehavior.neverClose) {
|
|
// Emit close after stdout data (two microtask ticks away)
|
|
const delay = mockBehavior.closeDelayMs ?? 0;
|
|
const schedule = delay > 0
|
|
? (fn: () => void) => setTimeout(fn, delay)
|
|
: (fn: () => void) =>
|
|
Promise.resolve()
|
|
.then(() => Promise.resolve())
|
|
.then(fn);
|
|
|
|
schedule(() => emit("close", mockBehavior.exitCode ?? 0));
|
|
}
|
|
},
|
|
kill: () => {},
|
|
};
|
|
|
|
return child;
|
|
}
|
|
|
|
return {
|
|
default: (_cmd: string, _args: string[]) => makeMockChild(),
|
|
};
|
|
});
|
|
|
|
// Import after mock registration
|
|
import { runAgent, _resetSemaphore } from "../../src/agent/runner";
|
|
import { _resetResolveClaudeCache } from "../../src/runtime/resolve-claude";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Shared workspace fixture
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("RunResult discriminated union", () => {
|
|
let workspaceDir: string;
|
|
const originalEnv = { ...process.env };
|
|
|
|
beforeAll(() => {
|
|
workspaceDir = fs.mkdtempSync(
|
|
path.join(os.tmpdir(), "disclaw-runresult-")
|
|
);
|
|
|
|
fs.writeFileSync(
|
|
path.join(workspaceDir, "agent.yaml"),
|
|
`name: result-agent\ndisplay_name: Result Agent\nrole: Testing\nchannel_id: "999"\n`,
|
|
"utf-8"
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(workspaceDir, "CLAUDE.md"),
|
|
"# Result Agent\nYou are a test agent.\n",
|
|
"utf-8"
|
|
);
|
|
});
|
|
|
|
beforeEach(() => {
|
|
mockBehavior = {};
|
|
_resetResolveClaudeCache();
|
|
_resetSemaphore(4);
|
|
process.env.CLAUDE_PATH = "/fake/claude";
|
|
});
|
|
|
|
afterEach(() => {
|
|
process.env = { ...originalEnv };
|
|
_resetResolveClaudeCache();
|
|
});
|
|
|
|
afterAll(() => {
|
|
try {
|
|
fs.rmSync(workspaceDir, { recursive: true, force: true });
|
|
} catch {
|
|
// Best-effort cleanup
|
|
}
|
|
});
|
|
|
|
// -------------------------------------------------------------------------
|
|
// (a) success
|
|
// -------------------------------------------------------------------------
|
|
it('(a) success: valid JSON stdout → status "success" with correct text', async () => {
|
|
setMockBehavior({
|
|
stdoutData: JSON.stringify({ result: "Agent says hi!" }),
|
|
exitCode: 0,
|
|
});
|
|
|
|
const result = await runAgent({
|
|
workspacePath: workspaceDir,
|
|
channelName: "test",
|
|
userMessage: "hello",
|
|
conversationHistory: [],
|
|
});
|
|
|
|
expect(result.status).toBe("success");
|
|
if (result.status === "success") {
|
|
expect(result.text).toBe("Agent says hi!");
|
|
}
|
|
});
|
|
|
|
// -------------------------------------------------------------------------
|
|
// (b) timeout
|
|
// -------------------------------------------------------------------------
|
|
it('(b) timeout: process never closes → status "timeout" with timeoutMs', async () => {
|
|
// Never emit close so the timeout fires. Use a very short timeout by
|
|
// writing a disclaw.yaml that sets claude_timeout_seconds to something tiny.
|
|
// Instead we rely on the fact that the timeout logic in runner.ts will kill
|
|
// the child (kill() is a no-op in the mock) and then the close event must
|
|
// still fire. We simulate this by making close fire *after* a delay longer
|
|
// than the timeout but still having `killed = true` set by the timer.
|
|
//
|
|
// The cleanest approach: let the mock emit close immediately but set
|
|
// killed=true before it fires by abusing the AbortController signal to
|
|
// trigger the kill path, and then emit close with code 0.
|
|
//
|
|
// Actually the simplest: set closeDelayMs to something > timeout, but
|
|
// the default timeout is 120s which is way too long for a test. Instead
|
|
// we use a custom disclaw.yaml in the workspace.
|
|
|
|
const configPath = path.join(workspaceDir, "disclaw.yaml");
|
|
fs.writeFileSync(
|
|
configPath,
|
|
`workspaces_root: /tmp\nmanagement_channel: disclaw\nclaude_command: claude\nclaude_timeout_seconds: 0.05\nmax_concurrent_agents: 4\nenv_blocklist: []\n`,
|
|
"utf-8"
|
|
);
|
|
|
|
// The mock's kill() is a no-op, so the close event will never arrive via
|
|
// the normal path. We need close to fire after the timeout kills the child.
|
|
// Set a small delay so close fires *after* the 50ms timeout.
|
|
setMockBehavior({
|
|
neverClose: false,
|
|
closeDelayMs: 200, // fires after the 50ms timeout
|
|
exitCode: 0,
|
|
});
|
|
|
|
// Override __dirname resolution: runner.ts resolves rootDir as 2 levels
|
|
// up from __dirname (src/agent -> root). We need to point it to our temp
|
|
// workspace. Use DISCLAW_ROOT env var is not available, so instead we
|
|
// write disclaw.yaml to the project root's expected location. This is
|
|
// tricky in a unit test — easier to just accept the default 120s timeout
|
|
// won't fire and instead mock the behavior differently.
|
|
//
|
|
// Simplest working approach: use AbortController to abort → child.kill()
|
|
// is called → mock emits close immediately with code 0 but killed=true.
|
|
// BUT abort doesn't set `killed` in the timeout branch — it sets it in
|
|
// the abort branch, and close code 0 after abort currently resolves success.
|
|
//
|
|
// The cleanest unit-testable path: just verify the timeout branch by
|
|
// patching the timeout to a very small value via the config file in the
|
|
// project root (since runner.ts uses loadDisclawConfig from 2 levels up).
|
|
|
|
// Clean up config to avoid affecting other tests
|
|
try {
|
|
fs.unlinkSync(configPath);
|
|
} catch {
|
|
// ignore
|
|
}
|
|
|
|
// Alternative approach: write config to project root temporarily
|
|
const projectRoot = path.resolve(__dirname, "../..");
|
|
const projectConfig = path.join(projectRoot, "disclaw.yaml");
|
|
const originalConfig = fs.existsSync(projectConfig)
|
|
? fs.readFileSync(projectConfig, "utf-8")
|
|
: null;
|
|
|
|
try {
|
|
fs.writeFileSync(
|
|
projectConfig,
|
|
`workspaces_root: /tmp\nmanagement_channel: disclaw\nclaude_command: claude\nclaude_timeout_seconds: 0.05\nmax_concurrent_agents: 4\nenv_blocklist: []\n`,
|
|
"utf-8"
|
|
);
|
|
|
|
setMockBehavior({
|
|
neverClose: false,
|
|
closeDelayMs: 200,
|
|
exitCode: 0,
|
|
});
|
|
|
|
const result = await runAgent({
|
|
workspacePath: workspaceDir,
|
|
channelName: "test",
|
|
userMessage: "hello",
|
|
conversationHistory: [],
|
|
});
|
|
|
|
expect(result.status).toBe("timeout");
|
|
if (result.status === "timeout") {
|
|
expect(result.timeoutMs).toBe(50);
|
|
}
|
|
} finally {
|
|
if (originalConfig !== null) {
|
|
fs.writeFileSync(projectConfig, originalConfig, "utf-8");
|
|
} else {
|
|
try {
|
|
fs.unlinkSync(projectConfig);
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
}
|
|
}, 10_000);
|
|
|
|
// -------------------------------------------------------------------------
|
|
// (c) cli-error: non-zero exit code
|
|
// -------------------------------------------------------------------------
|
|
it('(c) cli-error: exit code != 0 → status "cli-error"', async () => {
|
|
setMockBehavior({
|
|
stdoutData: "",
|
|
exitCode: 1,
|
|
});
|
|
|
|
const result = await runAgent({
|
|
workspacePath: workspaceDir,
|
|
channelName: "test",
|
|
userMessage: "hello",
|
|
conversationHistory: [],
|
|
});
|
|
|
|
expect(result.status).toBe("cli-error");
|
|
if (result.status === "cli-error") {
|
|
expect(result.message).toMatch(/Claude Code CLI error/);
|
|
}
|
|
});
|
|
|
|
// -------------------------------------------------------------------------
|
|
// (d) cli-error ENOENT
|
|
// -------------------------------------------------------------------------
|
|
it('(d) cli-error ENOENT: spawn error → status "cli-error" with "not found" message', async () => {
|
|
const enoentError = Object.assign(new Error("spawn ENOENT"), {
|
|
code: "ENOENT",
|
|
}) as NodeJS.ErrnoException;
|
|
|
|
setMockBehavior({
|
|
spawnError: enoentError,
|
|
});
|
|
|
|
const result = await runAgent({
|
|
workspacePath: workspaceDir,
|
|
channelName: "test",
|
|
userMessage: "hello",
|
|
conversationHistory: [],
|
|
});
|
|
|
|
expect(result.status).toBe("cli-error");
|
|
if (result.status === "cli-error") {
|
|
expect(result.message).toMatch(/not found/i);
|
|
expect(result.stderr).toBeUndefined();
|
|
}
|
|
});
|
|
});
|