| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187 |
- 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<T>(rpc: Rpc<T>): T {
- if (!rpc.success) throw new Error(rpc.message);
- return rpc.data;
- }
- function expectFailure<T>(rpc: Rpc<T>, 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<void>((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<void>((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('端点没有 Responses 路由时给出明确原因', async () => {
- expectFailure(
- await ctl.applyProvider({ modelId: 'x', baseUrl: notFoundUrl, apiKey: 'sk-nope' }),
- /没有 \/responses/,
- );
- }, 120_000);
- it('probeProvider 返回判定与 base_url 提醒', async () => {
- const result = await ctl.probeProvider({ baseUrl: notFoundUrl });
- const data = unwrap(result) as {
- protocol: string;
- responsesStatus: number | null;
- detail: string;
- hint: string | null;
- };
- expect(data.protocol).toBe('unsupported');
- expect(data.responsesStatus).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);
- });
- });
|