| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258 |
- import { createServer, type Server } from 'node:http';
- import { mkdtemp, mkdir, 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 { CodexRuntime } from './codexRuntime';
- import { ensureCodexHome, getSkillsDir } from './codexHome';
- import { McpService } from './mcpService';
- import { SkillService } from './skillService';
- import { applyProvider, clearProvider, readAppliedProvider } from './providerService';
- /**
- * 集成冒烟:真实 spawn codex app-server,但不需要账号登录、不需要真实模型端点。
- * 默认 npm test 不跑(.itest.ts),用 npm run smoke:codex 单独跑。
- */
- const API_KEY = 'sk-smoke-secret-value-1234567890';
- const SKILL_MD = ['---', 'name: smoke-skill', 'description: 冒烟测试用的技能', '---', '', '# smoke', ''].join('\n');
- let server: Server;
- let endpointStatus = 401;
- let baseUrl: string;
- let codexHome: string;
- let runtime: CodexRuntime;
- let tmpRoot: string;
- beforeAll(async () => {
- // 假端点:401 表示「路由存在但需要鉴权」,足以让 Chat 探测判为可用
- server = createServer((req, res) => {
- req.resume();
- res.writeHead(endpointStatus, { 'content-type': 'application/json' });
- res.end(JSON.stringify({ error: { message: 'smoke endpoint' } }));
- });
- 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;
- baseUrl = `http://127.0.0.1:${port}/v1`;
- codexHome = await ensureCodexHome();
- tmpRoot = await mkdtemp(join(tmpdir(), 'zsjz-codex-smoke-'));
- runtime = new CodexRuntime({
- codexHome,
- provider: { id: 'smoke', name: '冒烟端点', baseUrl, model: 'smoke-model', apiKey: API_KEY },
- });
- await runtime.start();
- }, 120_000);
- afterAll(async () => {
- await runtime?.dispose();
- await new Promise<void>((resolve) => server?.close(() => resolve()));
- await rm(tmpRoot, { recursive: true, force: true }).catch(() => undefined);
- });
- describe('免登录启动与配置注入', () => {
- it('自定义 provider 完成握手,config 里没有 api key', async () => {
- const status = runtime.status;
- expect(status.state).toBe('ready');
- expect(status.version).toMatch(/codex-cli/);
- expect(status.providerId).toBe('smoke');
- const config = await runtime.readConfig();
- expect(config.model_provider).toBe('smoke');
- expect(config.model).toBe('smoke-model');
- const entry = (config.model_providers as Record<string, Record<string, unknown>>)?.smoke;
- expect(entry?.base_url).toBe(baseUrl);
- expect(entry?.wire_api).toBe('responses');
- expect(entry?.requires_openai_auth).toBe(false);
- expect(entry?.env_key).toBe('ZSJZ_CODEX_API_KEY');
- expect(JSON.stringify(config)).not.toContain(API_KEY);
- });
- it('defaultModel 用 provider 配置的模型,不被内置目录默认值覆盖', () => {
- expect(runtime.status.defaultModel).toBe('smoke-model');
- expect(Array.isArray(runtime.status.models)).toBe(true);
- });
- it('未登录也能列 skill 与 MCP 状态', async () => {
- const skills = await runtime.listSkills({ forceReload: true });
- expect(Array.isArray(skills)).toBe(true);
- expect(skills.some((skill) => skill.scope === 'system')).toBe(true);
- expect(Array.isArray(await runtime.listMcpServerStatuses())).toBe(true);
- });
- });
- describe('MCP 配置热生效', () => {
- it('保存 → 出现在 config 与列表 → 删除后消失,全程不重启', async () => {
- const service = new McpService(runtime);
- const before = await service.list();
- const saved = await service.save({
- id: 'smoke-stdio',
- transport: 'stdio',
- command: 'node',
- args: ['-e', 'process.exit(0)'],
- enabled: false,
- });
- const entry = saved.find((item) => item.id === 'smoke-stdio');
- expect(entry).toMatchObject({ transport: 'stdio', command: 'node', enabled: false });
- expect(saved.length).toBe(before.length + 1);
- const config = await runtime.readConfig();
- const servers = config.mcp_servers as Record<string, Record<string, unknown>>;
- expect(servers['smoke-stdio']).toMatchObject({ command: 'node', enabled: false });
- const afterRemove = await service.remove('smoke-stdio');
- expect(afterRemove.find((item) => item.id === 'smoke-stdio')).toBeUndefined();
- expect(runtime.status.state).toBe('ready');
- });
- it('http 传输写入 url 与 bearer_token_env_var', async () => {
- const service = new McpService(runtime);
- await service.save({
- id: 'smoke-http',
- transport: 'http',
- url: 'http://127.0.0.1:9999/mcp',
- enabled: false,
- bearerTokenEnvVar: 'ZSJZ_SMOKE_TOKEN',
- });
- const config = await runtime.readConfig();
- const servers = config.mcp_servers as Record<string, Record<string, unknown>>;
- expect(servers['smoke-http']).toMatchObject({
- url: 'http://127.0.0.1:9999/mcp',
- bearer_token_env_var: 'ZSJZ_SMOKE_TOKEN',
- });
- await service.remove('smoke-http');
- });
- });
- describe('Skill 安装能被 Codex 识别', () => {
- it('装目录 → skills/list 可见 → 启停 → 卸载', async () => {
- const service = new SkillService(runtime);
- const source = join(tmpRoot, 'skill-src');
- await mkdir(join(source, 'references'), { recursive: true });
- await writeFile(join(source, 'SKILL.md'), SKILL_MD, 'utf8');
- await writeFile(join(source, 'references', 'note.md'), 'note', 'utf8');
- await service.installFromFolder(source, { overwrite: true });
- const installedDir = join(getSkillsDir(), 'smoke-skill');
- expect((await stat(join(installedDir, 'SKILL.md'))).isFile()).toBe(true);
- // 关键断言:我们自己写的目录布局确实被 Codex 认出来
- const listed = await service.list({ forceReload: true });
- const installed = listed.find((item) => item.name === 'smoke-skill');
- expect(installed).toBeTruthy();
- expect(installed?.managed).toBe(true);
- expect(installed?.description).toBe('冒烟测试用的技能');
- expect(await service.setEnabled({ name: 'smoke-skill' }, false)).toBe(false);
- const disabled = await service.list({ forceReload: true });
- expect(disabled.find((item) => item.name === 'smoke-skill')?.enabled).toBe(false);
- expect(await service.setEnabled({ name: 'smoke-skill' }, true)).toBe(true);
- const read = await service.read({ name: 'smoke-skill' });
- expect(read.content).toContain('smoke-skill');
- expect(read.files.map((file) => file.path).sort()).toEqual(['SKILL.md', 'references/note.md']);
- await service.remove({ name: 'smoke-skill' });
- expect(await stat(installedDir).catch(() => null)).toBeNull();
- const afterRemove = await service.list({ forceReload: true });
- expect(afterRemove.find((item) => item.name === 'smoke-skill')).toBeUndefined();
- });
- it('删除后 config 里的残留条目要真的被清掉', async () => {
- const service = new SkillService(runtime);
- const source = join(tmpRoot, 'stale-skill');
- await mkdir(source, { recursive: true });
- await writeFile(join(source, 'SKILL.md'), SKILL_MD.replace('smoke-skill', 'stale-skill'), 'utf8');
- await service.installFromFolder(source, { overwrite: true });
- await service.setEnabled({ name: 'stale-skill' }, false);
- const before = await runtime.readConfig();
- expect(JSON.stringify(before)).toContain('stale-skill');
- await service.remove({ name: 'stale-skill' });
- const after = await runtime.readConfig();
- expect(JSON.stringify(after)).not.toContain('stale-skill');
- }, 120_000);
- it('内置 skill 被标记为不可管理', async () => {
- const service = new SkillService(runtime);
- const items = await service.list();
- const builtin = items.find((item) => item.scope === 'system');
- expect(builtin?.managed).toBe(false);
- if (builtin) {
- await expect(service.remove({ path: builtin.path })).rejects.toThrow(/内置或暂存目录/);
- }
- });
- });
- describe('切换 provider 会重启子进程', () => {
- it('applyProvider 后 config 更新且磁盘上没有密钥', async () => {
- endpointStatus = 401;
- const applied = await applyProvider(runtime, {
- modelId: 'smoke-model-2',
- name: '冒烟端点 2',
- baseUrl,
- apiKey: API_KEY,
- modelRecordId: 42,
- });
- expect(applied).toMatchObject({ model: 'smoke-model-2', providerId: 'zsjz', modelRecordId: '42' });
- expect(runtime.status.state).toBe('ready');
- const config = await runtime.readConfig();
- expect(config.model_provider).toBe('zsjz');
- expect(config.model).toBe('smoke-model-2');
- expect(JSON.stringify(config)).not.toContain(API_KEY);
- const persisted = await readAppliedProvider();
- expect(persisted?.model).toBe('smoke-model-2');
- expect(JSON.stringify(persisted)).not.toContain(API_KEY);
- });
- it('应用直连上游真实地址,不做协议转换', async () => {
- endpointStatus = 200;
- const applied = await applyProvider(runtime, {
- modelId: 'smoke-responses-model',
- baseUrl,
- apiKey: API_KEY,
- });
- expect(applied.baseUrl).toBe(baseUrl);
- const config = await runtime.readConfig();
- expect(config.model_provider).toBe('zsjz');
- const entry = (config.model_providers as Record<string, Record<string, unknown>> | undefined)?.zsjz;
- expect(entry?.base_url).toBe(baseUrl);
- expect(entry?.wire_api).toBe('responses');
- // 空闲窗口必须显著大于 Codex 默认的 300 秒,否则长 prefill 会被判成流故障
- expect(entry?.stream_idle_timeout_ms).toBe(1_800_000);
- expect(entry?.stream_max_retries).toBe(0);
- expect(entry?.request_max_retries).toBe(0);
- });
- it('没有 Responses 路由的端点拒绝应用', async () => {
- endpointStatus = 404;
- await expect(applyProvider(runtime, { modelId: 'x', baseUrl, apiKey: API_KEY })).rejects.toThrow(
- /没有 \/responses/,
- );
- });
- it('clearProvider 回到无 provider 状态', async () => {
- await clearProvider(runtime);
- expect(await readAppliedProvider()).toBeNull();
- expect(runtime.provider).toBeNull();
- endpointStatus = 401;
- });
- });
- describe('停止与回收', () => {
- it('stop 后状态归零,未经懒启动的调用直接失败', async () => {
- await runtime.stop();
- expect(runtime.status.state).toBe('stopped');
- await expect(runtime.interruptTurn('thread-x', 'turn-x')).rejects.toThrow(/未运行/);
- });
- it('诊断信息会屏蔽密钥形态的字符串', async () => {
- const { sanitizeDiagnostic } = await import('./codexRuntime');
- expect(sanitizeDiagnostic('token sk-abcdefghij1234567890 leaked')).not.toContain('sk-abcdefghij1234567890');
- expect(sanitizeDiagnostic('Authorization: Bearer abc.def-ghi')).toContain('[redacted]');
- });
- });
|