import { createServer, type Server } from 'node:http'; import { mkdir, mkdtemp, rm, stat, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import CodexCtl from '../../controller/codexCtl'; import { disposeCodex } from './index'; import type { Rpc } from './types'; /** * controller 层集成冒烟:绕过 Electron 传输(P0 已单独验证), * 直接实例化 CodexCtl,覆盖 guard() 异常包装 + 真实 codex 子进程 + MCP/Skill 全链路。 */ function unwrap(rpc: Rpc): T { if (!rpc.success) throw new Error(rpc.message); return rpc.data; } function expectFailure(rpc: Rpc, pattern: RegExp): void { expect(rpc.success).toBe(false); if (!rpc.success) expect(rpc.message).toMatch(pattern); } const SKILL_MD = ['---', 'name: ctl-smoke', 'description: controller 冒烟用技能', '---', '', '# ctl', ''].join('\n'); const ctl = new CodexCtl(); let server: Server; let notFoundUrl: string; let tmpRoot: string; beforeAll(async () => { server = createServer((req, res) => { req.resume(); // 一律 404:端点活着但没有任何可用路由(含 /chat/completions) res.writeHead(404, { 'content-type': 'application/json' }); res.end('{}'); }); await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); const address = server.address(); const port = typeof address === 'object' && address ? address.port : 0; notFoundUrl = `http://127.0.0.1:${port}/v1`; tmpRoot = await mkdtemp(join(tmpdir(), 'zsjz-ctl-smoke-')); }, 120_000); afterAll(async () => { await disposeCodex(); await new Promise((resolve) => server?.close(() => resolve())); await rm(tmpRoot, { recursive: true, force: true }).catch(() => undefined); }); describe('CodexCtl 运行时', () => { it('ping 返回真实 codex 版本与 CODEX_HOME', async () => { const data = unwrap(await ctl.ping()); expect(data.available).toBe(true); expect(data.version).toMatch(/codex-cli/); expect(data.codexHome).toBeTruthy(); }); it('status 不 spawn 子进程也能返回', async () => { const data = unwrap(await ctl.status()); expect(['stopped', 'starting', 'ready', 'error']).toContain(data.state); expect(data.codexHome).toBeTruthy(); expect(data.applied === null || typeof data.applied === 'object').toBe(true); }); it('start / stop 改变运行状态', async () => { const started = unwrap(await ctl.start()); expect(started.running).toBe(true); const stopped = unwrap(await ctl.stop()); expect(stopped.state).toBe('stopped'); }, 120_000); }); describe('CodexCtl MCP', () => { it('新增 → 列表 → 删除', async () => { const saved = unwrap( await ctl.mcpSave({ id: 'ctl-stdio', transport: 'stdio', command: 'node', args: ['-e', 'process.exit(0)'], enabled: true, }), ); expect(saved.find((item) => item.id === 'ctl-stdio')).toMatchObject({ transport: 'stdio', command: 'node', enabled: true, }); const list = unwrap(await ctl.mcpList()); expect(list.map((item) => item.id)).toContain('ctl-stdio'); const removed = unwrap(await ctl.mcpRemove({ id: 'ctl-stdio' })); expect(removed.find((item) => item.id === 'ctl-stdio')).toBeUndefined(); }, 120_000); it('非法入参被 guard 包成 success:false,不抛给 Electron', async () => { expectFailure(await ctl.mcpSave({ id: '有 空格', transport: 'stdio', command: 'x', enabled: true }), /MCP ID/); expectFailure(await ctl.mcpRemove({}), /不能为空/); expectFailure( await ctl.mcpSave({ id: 'a', transport: 'http', url: 'http://10.1.2.3/mcp', enabled: true }), /HTTPS/, ); }, 120_000); }); describe('CodexCtl Skill', () => { it('从目录安装 → 列表可见 → 启停 → 卸载', async () => { const source = join(tmpRoot, 'skill'); await mkdir(source, { recursive: true }); await writeFile(join(source, 'SKILL.md'), SKILL_MD, 'utf8'); const installed = unwrap(await ctl.skillInstallFolder({ srcPath: source })); const item = installed.find((skill) => skill.name === 'ctl-smoke'); expect(item).toBeTruthy(); expect(item?.managed).toBe(true); expect(item?.description).toBe('controller 冒烟用技能'); const toggled = unwrap(await ctl.skillSetEnabled({ path: item!.path, enabled: false })); expect(toggled.effectiveEnabled).toBe(false); const read = unwrap(await ctl.skillRead({ name: 'ctl-smoke' })); expect(read.content).toContain('ctl-smoke'); const removed = unwrap(await ctl.skillRemove({ name: 'ctl-smoke' })); expect(removed.find((skill) => skill.name === 'ctl-smoke')).toBeUndefined(); expect(await stat(item!.path).catch(() => null)).toBeNull(); }, 180_000); it('内置 skill 不可卸载', async () => { const list = unwrap(await ctl.skillList({ forceReload: true })); const builtin = list.find((skill) => !skill.managed); expect(builtin).toBeTruthy(); expectFailure(await ctl.skillRemove({ path: builtin!.path }), /内置或暂存目录/); }, 120_000); it('zip 字节安装与非法字节都被正确处理', async () => { expectFailure(await ctl.skillInstallZip({ data: [1, 2, 3] }), /无法解析 zip/); expectFailure(await ctl.skillInstallFolder({ srcPath: join(tmpRoot, 'not-exist') }), /不是一个目录/); }, 120_000); }); describe('CodexCtl 模型 provider', () => { it('端点没有 Chat 路由时给出明确原因', async () => { expectFailure( await ctl.applyProvider({ modelId: 'x', baseUrl: notFoundUrl, apiKey: 'sk-nope' }), /没有 \/chat\/completions/, ); }, 120_000); it('probeProvider 返回判定与 base_url 提醒', async () => { const result = await ctl.probeProvider({ baseUrl: notFoundUrl }); const data = unwrap(result) as { protocol: string; chatStatus: number | null; detail: string; hint: string | null; }; expect(data.protocol).toBe('unsupported'); expect(data.chatStatus).toBe(404); expect(data.hint).toBeNull(); }, 120_000); it('缺 modelId 时报错', async () => { expectFailure(await ctl.applyProvider({ modelId: ' ' }), /modelId/); }, 120_000); }); describe('CodexCtl 会话与诊断', () => { it('turnInterrupt 缺参时报错而不是抛异常', async () => { expectFailure(await ctl.turnInterrupt({}), /不能为空/); }); it('approvalResolve 校验 decision 白名单', async () => { expectFailure( await ctl.approvalResolve({ token: 'nope', decision: 'whatever' as never }), /decision 不合法/, ); }); it('logsTail 返回数组', async () => { const logs = unwrap(await ctl.logsTail({ limit: 10 })); expect(Array.isArray(logs)).toBe(true); }); });