codexCtl.itest.ts 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185
  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. res.writeHead(req.url?.endsWith('/responses') ? 404 : 200, { 'content-type': 'application/json' });
  30. res.end('{}');
  31. });
  32. await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
  33. const address = server.address();
  34. const port = typeof address === 'object' && address ? address.port : 0;
  35. notFoundUrl = `http://127.0.0.1:${port}/v1`;
  36. tmpRoot = await mkdtemp(join(tmpdir(), 'zsjz-ctl-smoke-'));
  37. }, 120_000);
  38. afterAll(async () => {
  39. await disposeCodex();
  40. await new Promise<void>((resolve) => server?.close(() => resolve()));
  41. await rm(tmpRoot, { recursive: true, force: true }).catch(() => undefined);
  42. });
  43. describe('CodexCtl 运行时', () => {
  44. it('ping 返回真实 codex 版本与 CODEX_HOME', async () => {
  45. const data = unwrap(await ctl.ping());
  46. expect(data.available).toBe(true);
  47. expect(data.version).toMatch(/codex-cli/);
  48. expect(data.codexHome).toBeTruthy();
  49. });
  50. it('status 不 spawn 子进程也能返回', async () => {
  51. const data = unwrap(await ctl.status());
  52. expect(['stopped', 'starting', 'ready', 'error']).toContain(data.state);
  53. expect(data.codexHome).toBeTruthy();
  54. expect(data.applied === null || typeof data.applied === 'object').toBe(true);
  55. });
  56. it('start / stop 改变运行状态', async () => {
  57. const started = unwrap(await ctl.start());
  58. expect(started.running).toBe(true);
  59. const stopped = unwrap(await ctl.stop());
  60. expect(stopped.state).toBe('stopped');
  61. }, 120_000);
  62. });
  63. describe('CodexCtl MCP', () => {
  64. it('新增 → 列表 → 删除', async () => {
  65. const saved = unwrap(
  66. await ctl.mcpSave({
  67. id: 'ctl-stdio',
  68. transport: 'stdio',
  69. command: 'node',
  70. args: ['-e', 'process.exit(0)'],
  71. enabled: true,
  72. }),
  73. );
  74. expect(saved.find((item) => item.id === 'ctl-stdio')).toMatchObject({
  75. transport: 'stdio',
  76. command: 'node',
  77. enabled: true,
  78. });
  79. const list = unwrap(await ctl.mcpList());
  80. expect(list.map((item) => item.id)).toContain('ctl-stdio');
  81. const removed = unwrap(await ctl.mcpRemove({ id: 'ctl-stdio' }));
  82. expect(removed.find((item) => item.id === 'ctl-stdio')).toBeUndefined();
  83. }, 120_000);
  84. it('非法入参被 guard 包成 success:false,不抛给 Electron', async () => {
  85. expectFailure(await ctl.mcpSave({ id: '有 空格', transport: 'stdio', command: 'x', enabled: true }), /MCP ID/);
  86. expectFailure(await ctl.mcpRemove({}), /不能为空/);
  87. expectFailure(
  88. await ctl.mcpSave({ id: 'a', transport: 'http', url: 'http://10.1.2.3/mcp', enabled: true }),
  89. /HTTPS/,
  90. );
  91. }, 120_000);
  92. });
  93. describe('CodexCtl Skill', () => {
  94. it('从目录安装 → 列表可见 → 启停 → 卸载', async () => {
  95. const source = join(tmpRoot, 'skill');
  96. await mkdir(source, { recursive: true });
  97. await writeFile(join(source, 'SKILL.md'), SKILL_MD, 'utf8');
  98. const installed = unwrap(await ctl.skillInstallFolder({ srcPath: source }));
  99. const item = installed.find((skill) => skill.name === 'ctl-smoke');
  100. expect(item).toBeTruthy();
  101. expect(item?.managed).toBe(true);
  102. expect(item?.description).toBe('controller 冒烟用技能');
  103. const toggled = unwrap(await ctl.skillSetEnabled({ path: item!.path, enabled: false }));
  104. expect(toggled.effectiveEnabled).toBe(false);
  105. const read = unwrap(await ctl.skillRead({ name: 'ctl-smoke' }));
  106. expect(read.content).toContain('ctl-smoke');
  107. const removed = unwrap(await ctl.skillRemove({ name: 'ctl-smoke' }));
  108. expect(removed.find((skill) => skill.name === 'ctl-smoke')).toBeUndefined();
  109. expect(await stat(item!.path).catch(() => null)).toBeNull();
  110. }, 180_000);
  111. it('内置 skill 不可卸载', async () => {
  112. const list = unwrap(await ctl.skillList({ forceReload: true }));
  113. const builtin = list.find((skill) => !skill.managed);
  114. expect(builtin).toBeTruthy();
  115. expectFailure(await ctl.skillRemove({ path: builtin!.path }), /内置或暂存目录/);
  116. }, 120_000);
  117. it('zip 字节安装与非法字节都被正确处理', async () => {
  118. expectFailure(await ctl.skillInstallZip({ data: [1, 2, 3] }), /无法解析 zip/);
  119. expectFailure(await ctl.skillInstallFolder({ srcPath: join(tmpRoot, 'not-exist') }), /不是一个目录/);
  120. }, 120_000);
  121. });
  122. describe('CodexCtl 模型 provider', () => {
  123. it('探测到未实现 Responses 的端点时给出明确原因', async () => {
  124. expectFailure(
  125. await ctl.applyProvider({ modelId: 'x', baseUrl: notFoundUrl, apiKey: 'sk-nope', providerType: 'OPENAI' }),
  126. /未实现 Responses API/,
  127. );
  128. }, 120_000);
  129. it('probeProvider 返回兼容性与 base_url 提醒', async () => {
  130. const result = (await ctl.probeProvider({ baseUrl: notFoundUrl })) as Rpc<{
  131. compatible: boolean;
  132. status: number | null;
  133. detail: string;
  134. hint: string | null;
  135. }>;
  136. const data = unwrap(result);
  137. expect(data.compatible).toBe(false);
  138. expect(data.status).toBe(404);
  139. }, 120_000);
  140. it('缺 modelId 时报错', async () => {
  141. expectFailure(await ctl.applyProvider({ modelId: ' ' }), /modelId/);
  142. }, 120_000);
  143. });
  144. describe('CodexCtl 会话与诊断', () => {
  145. it('turnInterrupt 缺参时报错而不是抛异常', async () => {
  146. expectFailure(await ctl.turnInterrupt({}), /不能为空/);
  147. });
  148. it('approvalResolve 校验 decision 白名单', async () => {
  149. expectFailure(
  150. await ctl.approvalResolve({ token: 'nope', decision: 'whatever' as never }),
  151. /decision 不合法/,
  152. );
  153. });
  154. it('logsTail 返回数组', async () => {
  155. const logs = unwrap(await ctl.logsTail({ limit: 10 }));
  156. expect(Array.isArray(logs)).toBe(true);
  157. });
  158. });