codexCtl.itest.ts 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187
  1. import { createServer, type Server } from 'node:http';
  2. import { mkdir, mkdtemp, rm, stat, writeFile } from 'node:fs/promises';
  3. import { tmpdir } from 'node:os';
  4. import { join } from 'node:path';
  5. import { afterAll, beforeAll, describe, expect, it } from 'vitest';
  6. import CodexCtl from '../../controller/codexCtl';
  7. import { disposeCodex } from './index';
  8. import type { Rpc } from './types';
  9. /**
  10. * controller 层集成冒烟:绕过 Electron 传输(P0 已单独验证),
  11. * 直接实例化 CodexCtl,覆盖 guard() 异常包装 + 真实 codex 子进程 + MCP/Skill 全链路。
  12. */
  13. function unwrap<T>(rpc: Rpc<T>): T {
  14. if (!rpc.success) throw new Error(rpc.message);
  15. return rpc.data;
  16. }
  17. function expectFailure<T>(rpc: Rpc<T>, pattern: RegExp): void {
  18. expect(rpc.success).toBe(false);
  19. if (!rpc.success) expect(rpc.message).toMatch(pattern);
  20. }
  21. const SKILL_MD = ['---', 'name: ctl-smoke', 'description: controller 冒烟用技能', '---', '', '# ctl', ''].join('\n');
  22. const ctl = new CodexCtl();
  23. let server: Server;
  24. let notFoundUrl: string;
  25. let tmpRoot: string;
  26. beforeAll(async () => {
  27. server = createServer((req, res) => {
  28. req.resume();
  29. // 一律 404:端点活着但没有任何可用路由(含 /chat/completions)
  30. res.writeHead(404, { 'content-type': 'application/json' });
  31. res.end('{}');
  32. });
  33. await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
  34. const address = server.address();
  35. const port = typeof address === 'object' && address ? address.port : 0;
  36. notFoundUrl = `http://127.0.0.1:${port}/v1`;
  37. tmpRoot = await mkdtemp(join(tmpdir(), 'zsjz-ctl-smoke-'));
  38. }, 120_000);
  39. afterAll(async () => {
  40. await disposeCodex();
  41. await new Promise<void>((resolve) => server?.close(() => resolve()));
  42. await rm(tmpRoot, { recursive: true, force: true }).catch(() => undefined);
  43. });
  44. describe('CodexCtl 运行时', () => {
  45. it('ping 返回真实 codex 版本与 CODEX_HOME', async () => {
  46. const data = unwrap(await ctl.ping());
  47. expect(data.available).toBe(true);
  48. expect(data.version).toMatch(/codex-cli/);
  49. expect(data.codexHome).toBeTruthy();
  50. });
  51. it('status 不 spawn 子进程也能返回', async () => {
  52. const data = unwrap(await ctl.status());
  53. expect(['stopped', 'starting', 'ready', 'error']).toContain(data.state);
  54. expect(data.codexHome).toBeTruthy();
  55. expect(data.applied === null || typeof data.applied === 'object').toBe(true);
  56. });
  57. it('start / stop 改变运行状态', async () => {
  58. const started = unwrap(await ctl.start());
  59. expect(started.running).toBe(true);
  60. const stopped = unwrap(await ctl.stop());
  61. expect(stopped.state).toBe('stopped');
  62. }, 120_000);
  63. });
  64. describe('CodexCtl MCP', () => {
  65. it('新增 → 列表 → 删除', async () => {
  66. const saved = unwrap(
  67. await ctl.mcpSave({
  68. id: 'ctl-stdio',
  69. transport: 'stdio',
  70. command: 'node',
  71. args: ['-e', 'process.exit(0)'],
  72. enabled: true,
  73. }),
  74. );
  75. expect(saved.find((item) => item.id === 'ctl-stdio')).toMatchObject({
  76. transport: 'stdio',
  77. command: 'node',
  78. enabled: true,
  79. });
  80. const list = unwrap(await ctl.mcpList());
  81. expect(list.map((item) => item.id)).toContain('ctl-stdio');
  82. const removed = unwrap(await ctl.mcpRemove({ id: 'ctl-stdio' }));
  83. expect(removed.find((item) => item.id === 'ctl-stdio')).toBeUndefined();
  84. }, 120_000);
  85. it('非法入参被 guard 包成 success:false,不抛给 Electron', async () => {
  86. expectFailure(await ctl.mcpSave({ id: '有 空格', transport: 'stdio', command: 'x', enabled: true }), /MCP ID/);
  87. expectFailure(await ctl.mcpRemove({}), /不能为空/);
  88. expectFailure(
  89. await ctl.mcpSave({ id: 'a', transport: 'http', url: 'http://10.1.2.3/mcp', enabled: true }),
  90. /HTTPS/,
  91. );
  92. }, 120_000);
  93. });
  94. describe('CodexCtl Skill', () => {
  95. it('从目录安装 → 列表可见 → 启停 → 卸载', async () => {
  96. const source = join(tmpRoot, 'skill');
  97. await mkdir(source, { recursive: true });
  98. await writeFile(join(source, 'SKILL.md'), SKILL_MD, 'utf8');
  99. const installed = unwrap(await ctl.skillInstallFolder({ srcPath: source }));
  100. const item = installed.find((skill) => skill.name === 'ctl-smoke');
  101. expect(item).toBeTruthy();
  102. expect(item?.managed).toBe(true);
  103. expect(item?.description).toBe('controller 冒烟用技能');
  104. const toggled = unwrap(await ctl.skillSetEnabled({ path: item!.path, enabled: false }));
  105. expect(toggled.effectiveEnabled).toBe(false);
  106. const read = unwrap(await ctl.skillRead({ name: 'ctl-smoke' }));
  107. expect(read.content).toContain('ctl-smoke');
  108. const removed = unwrap(await ctl.skillRemove({ name: 'ctl-smoke' }));
  109. expect(removed.find((skill) => skill.name === 'ctl-smoke')).toBeUndefined();
  110. expect(await stat(item!.path).catch(() => null)).toBeNull();
  111. }, 180_000);
  112. it('内置 skill 不可卸载', async () => {
  113. const list = unwrap(await ctl.skillList({ forceReload: true }));
  114. const builtin = list.find((skill) => !skill.managed);
  115. expect(builtin).toBeTruthy();
  116. expectFailure(await ctl.skillRemove({ path: builtin!.path }), /内置或暂存目录/);
  117. }, 120_000);
  118. it('zip 字节安装与非法字节都被正确处理', async () => {
  119. expectFailure(await ctl.skillInstallZip({ data: [1, 2, 3] }), /无法解析 zip/);
  120. expectFailure(await ctl.skillInstallFolder({ srcPath: join(tmpRoot, 'not-exist') }), /不是一个目录/);
  121. }, 120_000);
  122. });
  123. describe('CodexCtl 模型 provider', () => {
  124. it('端点没有 Chat 路由时给出明确原因', async () => {
  125. expectFailure(
  126. await ctl.applyProvider({ modelId: 'x', baseUrl: notFoundUrl, apiKey: 'sk-nope' }),
  127. /没有 \/chat\/completions/,
  128. );
  129. }, 120_000);
  130. it('probeProvider 返回判定与 base_url 提醒', async () => {
  131. const result = await ctl.probeProvider({ baseUrl: notFoundUrl });
  132. const data = unwrap(result) as {
  133. protocol: string;
  134. chatStatus: number | null;
  135. detail: string;
  136. hint: string | null;
  137. };
  138. expect(data.protocol).toBe('unsupported');
  139. expect(data.chatStatus).toBe(404);
  140. expect(data.hint).toBeNull();
  141. }, 120_000);
  142. it('缺 modelId 时报错', async () => {
  143. expectFailure(await ctl.applyProvider({ modelId: ' ' }), /modelId/);
  144. }, 120_000);
  145. });
  146. describe('CodexCtl 会话与诊断', () => {
  147. it('turnInterrupt 缺参时报错而不是抛异常', async () => {
  148. expectFailure(await ctl.turnInterrupt({}), /不能为空/);
  149. });
  150. it('approvalResolve 校验 decision 白名单', async () => {
  151. expectFailure(
  152. await ctl.approvalResolve({ token: 'nope', decision: 'whatever' as never }),
  153. /decision 不合法/,
  154. );
  155. });
  156. it('logsTail 返回数组', async () => {
  157. const logs = unwrap(await ctl.logsTail({ limit: 10 }));
  158. expect(Array.isArray(logs)).toBe(true);
  159. });
  160. });