- Add MEMENTO_MCP_SSE_URL/ACCESS_KEY/REMOTE_PATH to readEnvFile - Merge .env vars into cleanEnv for runner process inheritance - Claude Code runner: add memento-mcp via mcp-remote in mcpServers - Codex runner: inject memento-mcp section into config.toml - Various improvements: CI watch, task scheduler, DB, IPC auth
298 lines
7.7 KiB
TypeScript
298 lines
7.7 KiB
TypeScript
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
|
import { EventEmitter } from 'events';
|
|
import { PassThrough } from 'stream';
|
|
import fs from 'fs';
|
|
|
|
// Sentinel markers must match agent-runner.ts
|
|
const OUTPUT_START_MARKER = '---NANOCLAW_OUTPUT_START---';
|
|
const OUTPUT_END_MARKER = '---NANOCLAW_OUTPUT_END---';
|
|
|
|
// Mock config
|
|
vi.mock('./config.js', () => ({
|
|
AGENT_MAX_OUTPUT_SIZE: 10485760,
|
|
AGENT_TIMEOUT: 1800000, // 30min
|
|
DATA_DIR: '/tmp/ejclaw-test-data',
|
|
GROUPS_DIR: '/tmp/ejclaw-test-groups',
|
|
IDLE_TIMEOUT: 1800000, // 30min
|
|
SERVICE_ID: 'claude',
|
|
SERVICE_AGENT_TYPE: 'claude-code',
|
|
TIMEZONE: 'America/Los_Angeles',
|
|
}));
|
|
|
|
// Mock logger
|
|
vi.mock('./logger.js', () => ({
|
|
logger: {
|
|
debug: vi.fn(),
|
|
info: vi.fn(),
|
|
warn: vi.fn(),
|
|
error: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
// Mock fs
|
|
vi.mock('fs', async () => {
|
|
const actual = await vi.importActual<typeof import('fs')>('fs');
|
|
return {
|
|
...actual,
|
|
default: {
|
|
...actual,
|
|
existsSync: vi.fn((p: string) => {
|
|
// Return true for runner dist entry so tests proceed past the build check
|
|
if (typeof p === 'string' && p.includes('dist/index.js')) return true;
|
|
return false;
|
|
}),
|
|
mkdirSync: vi.fn(),
|
|
writeFileSync: vi.fn(),
|
|
readFileSync: vi.fn(() => ''),
|
|
readdirSync: vi.fn(() => []),
|
|
statSync: vi.fn(() => ({ isDirectory: () => false })),
|
|
copyFileSync: vi.fn(),
|
|
cpSync: vi.fn(),
|
|
},
|
|
};
|
|
});
|
|
|
|
// Mock env
|
|
vi.mock('./env.js', () => ({
|
|
readEnvFile: vi.fn(() => ({})),
|
|
}));
|
|
|
|
// Create a controllable fake ChildProcess
|
|
function createFakeProcess() {
|
|
const proc = new EventEmitter() as EventEmitter & {
|
|
stdin: PassThrough;
|
|
stdout: PassThrough;
|
|
stderr: PassThrough;
|
|
kill: ReturnType<typeof vi.fn>;
|
|
pid: number;
|
|
};
|
|
proc.stdin = new PassThrough();
|
|
proc.stdout = new PassThrough();
|
|
proc.stderr = new PassThrough();
|
|
proc.kill = vi.fn();
|
|
proc.pid = 12345;
|
|
return proc;
|
|
}
|
|
|
|
let fakeProc: ReturnType<typeof createFakeProcess>;
|
|
|
|
// Mock child_process.spawn
|
|
vi.mock('child_process', async () => {
|
|
const actual =
|
|
await vi.importActual<typeof import('child_process')>('child_process');
|
|
return {
|
|
...actual,
|
|
spawn: vi.fn(() => fakeProc),
|
|
exec: vi.fn(
|
|
(_cmd: string, _opts: unknown, cb?: (err: Error | null) => void) => {
|
|
if (cb) cb(null);
|
|
return new EventEmitter();
|
|
},
|
|
),
|
|
};
|
|
});
|
|
|
|
import { runAgentProcess, AgentOutput } from './agent-runner.js';
|
|
import type { RegisteredGroup } from './types.js';
|
|
|
|
const testGroup: RegisteredGroup = {
|
|
name: 'Test Group',
|
|
folder: 'test-group',
|
|
trigger: '@Andy',
|
|
added_at: new Date().toISOString(),
|
|
};
|
|
|
|
const testInput = {
|
|
prompt: 'Hello',
|
|
groupFolder: 'test-group',
|
|
chatJid: 'test@g.us',
|
|
isMain: false,
|
|
};
|
|
|
|
function emitOutputMarker(
|
|
proc: ReturnType<typeof createFakeProcess>,
|
|
output: AgentOutput,
|
|
) {
|
|
const json = JSON.stringify(output);
|
|
proc.stdout.push(`${OUTPUT_START_MARKER}\n${json}\n${OUTPUT_END_MARKER}\n`);
|
|
}
|
|
|
|
describe('agent-runner timeout behavior', () => {
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
fakeProc = createFakeProcess();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it('timeout after output resolves as success', async () => {
|
|
const onOutput = vi.fn(async () => {});
|
|
const resultPromise = runAgentProcess(
|
|
testGroup,
|
|
testInput,
|
|
() => {},
|
|
onOutput,
|
|
);
|
|
|
|
// Emit output with a result
|
|
emitOutputMarker(fakeProc, {
|
|
status: 'success',
|
|
result: 'Here is my response',
|
|
newSessionId: 'session-123',
|
|
});
|
|
|
|
// Let output processing settle
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
|
|
// Fire the hard timeout (IDLE_TIMEOUT + 30s = 1830000ms)
|
|
await vi.advanceTimersByTimeAsync(1830000);
|
|
|
|
// Emit close event (as if agent was stopped by the timeout)
|
|
fakeProc.emit('close', 137);
|
|
|
|
// Let the promise resolve
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
|
|
const result = await resultPromise;
|
|
expect(result.status).toBe('success');
|
|
expect(result.newSessionId).toBe('session-123');
|
|
expect(onOutput).toHaveBeenCalledWith(
|
|
expect.objectContaining({ result: 'Here is my response' }),
|
|
);
|
|
});
|
|
|
|
it('timeout with no output resolves as error', async () => {
|
|
const onOutput = vi.fn(async () => {});
|
|
const resultPromise = runAgentProcess(
|
|
testGroup,
|
|
testInput,
|
|
() => {},
|
|
onOutput,
|
|
);
|
|
|
|
// No output emitted — fire the hard timeout
|
|
await vi.advanceTimersByTimeAsync(1830000);
|
|
|
|
// Emit close event
|
|
fakeProc.emit('close', 137);
|
|
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
|
|
const result = await resultPromise;
|
|
expect(result.status).toBe('error');
|
|
expect(result.error).toContain('timed out');
|
|
expect(onOutput).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('normal exit after output resolves as success', async () => {
|
|
const onOutput = vi.fn(async () => {});
|
|
const resultPromise = runAgentProcess(
|
|
testGroup,
|
|
testInput,
|
|
() => {},
|
|
onOutput,
|
|
);
|
|
|
|
// Emit output
|
|
emitOutputMarker(fakeProc, {
|
|
status: 'success',
|
|
result: 'Done',
|
|
newSessionId: 'session-456',
|
|
});
|
|
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
|
|
// Normal exit (no timeout)
|
|
fakeProc.emit('close', 0);
|
|
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
|
|
const result = await resultPromise;
|
|
expect(result.status).toBe('success');
|
|
expect(result.newSessionId).toBe('session-456');
|
|
});
|
|
|
|
it('materializes repo-managed Claude rules into the session CLAUDE.md', async () => {
|
|
vi.mocked(fs.existsSync).mockImplementation((p: fs.PathLike) => {
|
|
const path = String(p);
|
|
return (
|
|
path.includes('dist/index.js') ||
|
|
path.endsWith('/prompts/claude-platform.md') ||
|
|
path.endsWith('/global/CLAUDE.md')
|
|
);
|
|
});
|
|
vi.mocked(fs.readFileSync).mockImplementation(
|
|
(p: fs.PathOrFileDescriptor) => {
|
|
const path = String(p);
|
|
if (path.endsWith('/prompts/claude-platform.md')) {
|
|
return 'Platform Claude Rules';
|
|
}
|
|
if (path.endsWith('/global/CLAUDE.md')) {
|
|
return 'Global Claude Memory';
|
|
}
|
|
return '';
|
|
},
|
|
);
|
|
|
|
const resultPromise = runAgentProcess(
|
|
testGroup,
|
|
testInput,
|
|
() => {},
|
|
undefined,
|
|
);
|
|
|
|
fakeProc.emit('close', 0);
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
await resultPromise;
|
|
|
|
expect(fs.writeFileSync).toHaveBeenCalledWith(
|
|
'/tmp/ejclaw-test-data/sessions/test-group/.claude/CLAUDE.md',
|
|
'Platform Claude Rules\n\n---\n\nGlobal Claude Memory\n',
|
|
);
|
|
});
|
|
|
|
it('injects the real chat JID into Codex MCP config', async () => {
|
|
vi.mocked(fs.existsSync).mockImplementation((p: fs.PathLike) => {
|
|
const path = String(p);
|
|
return (
|
|
path.includes('dist/index.js') ||
|
|
path.endsWith('/runners/agent-runner/dist/ipc-mcp-stdio.js')
|
|
);
|
|
});
|
|
vi.mocked(fs.readFileSync).mockReturnValue('');
|
|
|
|
const codexGroup: RegisteredGroup = {
|
|
...testGroup,
|
|
folder: 'eyejokerdb-4',
|
|
agentType: 'codex',
|
|
};
|
|
const codexInput = {
|
|
...testInput,
|
|
groupFolder: 'eyejokerdb-4',
|
|
chatJid: 'dc:1481348008183595170',
|
|
};
|
|
|
|
const resultPromise = runAgentProcess(
|
|
codexGroup,
|
|
codexInput,
|
|
() => {},
|
|
undefined,
|
|
);
|
|
|
|
fakeProc.emit('close', 0);
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
await resultPromise;
|
|
|
|
expect(fs.writeFileSync).toHaveBeenCalledWith(
|
|
'/tmp/ejclaw-test-data/sessions/eyejokerdb-4/.codex/config.toml',
|
|
expect.stringContaining('NANOCLAW_CHAT_JID = "dc:1481348008183595170"'),
|
|
);
|
|
expect(fs.writeFileSync).toHaveBeenCalledWith(
|
|
'/tmp/ejclaw-test-data/sessions/eyejokerdb-4/.codex/config.toml',
|
|
expect.stringContaining('NANOCLAW_GROUP_FOLDER = "eyejokerdb-4"'),
|
|
);
|
|
});
|
|
});
|