Files
hapi/cli/src/codex/codexLocal.test.ts
T

214 lines
8.2 KiB
TypeScript

import { win32 } from 'node:path';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const { resolveCodexCommandMock, spawnWithTerminalGuardMock } = vi.hoisted(() => ({
resolveCodexCommandMock: vi.fn(() => ({ command: 'codex', args: [] as string[] })),
spawnWithTerminalGuardMock: vi.fn(async (_options: unknown) => {})
}));
vi.mock('./utils/codexExecutable', () => ({
resolveCodexCommand: resolveCodexCommandMock
}));
vi.mock('@/utils/spawnWithTerminalGuard', () => ({
spawnWithTerminalGuard: spawnWithTerminalGuardMock
}));
vi.mock('@/ui/logger', () => ({
logger: {
debug: vi.fn()
}
}));
import { codexLocal, filterResumeSubcommand } from './codexLocal';
const codexScriptPath = win32.join('toolchains', 'nodejs', 'node_modules', '@openai', 'codex', 'bin', 'codex.js');
const hapiCommandPath = win32.join('hapi-bin', 'hapi.exe');
const workspacePath = win32.join('workspace', 'project');
describe('filterResumeSubcommand', () => {
it('returns empty array unchanged', () => {
expect(filterResumeSubcommand([])).toEqual([]);
});
it('passes through args when first arg is not resume', () => {
expect(filterResumeSubcommand(['--model', 'gpt-4'])).toEqual(['--model', 'gpt-4']);
expect(filterResumeSubcommand(['--sandbox', 'read-only'])).toEqual(['--sandbox', 'read-only']);
});
it('filters resume subcommand with session ID', () => {
expect(filterResumeSubcommand(['resume', 'abc-123'])).toEqual([]);
expect(filterResumeSubcommand(['resume', 'abc-123', '--model', 'gpt-4']))
.toEqual(['--model', 'gpt-4']);
});
it('filters resume subcommand without session ID', () => {
expect(filterResumeSubcommand(['resume'])).toEqual([]);
expect(filterResumeSubcommand(['resume', '--model', 'gpt-4']))
.toEqual(['--model', 'gpt-4']);
});
it('does not filter resume when it appears as an option value', () => {
expect(filterResumeSubcommand(['--model', 'resume'])).toEqual(['--model', 'resume']);
});
it('filters resume after global options', () => {
expect(filterResumeSubcommand(['--model', 'gpt-4', 'resume', '123']))
.toEqual(['--model', 'gpt-4']);
expect(filterResumeSubcommand(['--yolo', 'resume', '--last']))
.toEqual(['--yolo']);
expect(filterResumeSubcommand(['--config', 'model="resume"', 'resume', '--last']))
.toEqual(['--config', 'model="resume"']);
});
it('preserves an optional prompt after the resume selector', () => {
expect(filterResumeSubcommand(['resume', 'abc-123', 'continue here']))
.toEqual(['continue here']);
expect(filterResumeSubcommand(['resume', 'abc-123', 'resume']))
.toEqual(['resume']);
expect(filterResumeSubcommand(['resume', '--last', 'continue here']))
.toEqual(['continue here']);
});
it('does not filter resume after a prompt or option terminator', () => {
expect(filterResumeSubcommand(['start here', 'resume', '--last']))
.toEqual(['start here', 'resume', '--last']);
expect(filterResumeSubcommand(['--', 'resume', '--last']))
.toEqual(['--', 'resume', '--last']);
});
it('does not treat variadic image values as a resume subcommand', () => {
expect(filterResumeSubcommand(['--image', 'one.png', 'resume', '--last']))
.toEqual(['--image', 'one.png', 'resume', '--last']);
});
it('preserves arguments after an option terminator in resume mode', () => {
expect(filterResumeSubcommand(['resume', 'abc-123', '--', '--last']))
.toEqual(['--', '--last']);
});
});
describe('codexLocal', () => {
beforeEach(() => {
resolveCodexCommandMock.mockReset();
resolveCodexCommandMock.mockReturnValue({ command: 'codex', args: [] as string[] });
spawnWithTerminalGuardMock.mockClear();
});
it('launches the resolved Codex command without shell so Windows keeps -c config values as argv elements', async () => {
const controller = new AbortController();
resolveCodexCommandMock.mockReturnValue({
command: 'node',
args: [codexScriptPath]
});
await codexLocal({
abort: controller.signal,
sessionId: null,
path: workspacePath,
onSessionFound: vi.fn(),
mcpServers: {
hapi: {
command: hapiCommandPath,
args: ['mcp', '--url', 'http://127.0.0.1:63995/'],
tools: {
change_title: {
approval_mode: 'approve'
}
}
}
},
sessionHook: {
port: 63996,
token: 'secret-token'
}
});
expect(spawnWithTerminalGuardMock).toHaveBeenCalledOnce();
const spawnOptions = spawnWithTerminalGuardMock.mock.calls[0][0] as {
command: string;
cwd: string;
args: string[];
shell?: unknown;
};
expect(spawnOptions).toEqual(expect.objectContaining({
command: 'node',
cwd: workspacePath
}));
expect(spawnOptions).not.toHaveProperty('shell');
const args = spawnOptions.args;
expect(args[0]).toBe(codexScriptPath);
const hookArg = args.find((arg) => arg.startsWith('hooks.SessionStart='));
expect(hookArg).toBeDefined();
expect(hookArg).toContain('{ hooks = [{ type = "command", command = "');
expect(args.some((arg) => arg.startsWith('hooks.PreToolUse='))).toBe(true);
expect(args.some((arg) => arg.startsWith('hooks.PostToolUse='))).toBe(true);
expect(args).toContain("mcp_servers.hapi.args=['mcp','--url','http://127.0.0.1:63995/']");
expect(args).toContain('mcp_servers.hapi.tools.change_title.approval_mode="approve"');
});
it('passes reasoning effort through Codex config instead of an unsupported CLI flag', async () => {
const controller = new AbortController();
await codexLocal({
abort: controller.signal,
sessionId: 'codex-session-1',
path: workspacePath,
modelReasoningEffort: 'high',
onSessionFound: vi.fn()
});
expect(spawnWithTerminalGuardMock).toHaveBeenCalledOnce();
const spawnOptions = spawnWithTerminalGuardMock.mock.calls[0][0] as {
args: string[];
};
expect(spawnOptions.args).toContain('-c');
expect(spawnOptions.args).toContain('model_reasoning_effort="high"');
expect(spawnOptions.args).not.toContain('--model-reasoning-effort');
});
it('passes resume --last through while Codex is resolving the initial session', async () => {
const controller = new AbortController();
await codexLocal({
abort: controller.signal,
sessionId: null,
path: workspacePath,
onSessionFound: vi.fn(),
codexArgs: ['--yolo', 'resume', '--last']
});
const spawnOptions = spawnWithTerminalGuardMock.mock.calls[0][0] as {
args: string[];
};
expect(spawnOptions.args.slice(-3)).toEqual(['--yolo', 'resume', '--last']);
});
it('replaces resume --last with the resolved session ID during local handoff', async () => {
const controller = new AbortController();
const sessionId = '11111111-1111-4111-8111-111111111111';
await codexLocal({
abort: controller.signal,
sessionId,
path: workspacePath,
onSessionFound: vi.fn(),
codexArgs: [
'--ask-for-approval', 'never',
'--sandbox', 'danger-full-access',
'resume', '--last'
]
});
const spawnOptions = spawnWithTerminalGuardMock.mock.calls[0][0] as {
args: string[];
};
expect(spawnOptions.args.filter((arg) => arg === 'resume')).toEqual(['resume']);
expect(spawnOptions.args).toContain(sessionId);
expect(spawnOptions.args).not.toContain('--last');
expect(spawnOptions.args).toContain('danger-full-access');
});
});