codexmate 0.0.6 → 0.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
- {
1
+ {
2
2
  "name": "codexmate",
3
- "version": "0.0.6",
3
+ "version": "0.0.8",
4
4
  "description": "Codex/Claude Code 配置与会话管理 CLI + Web 工具",
5
5
  "bin": {
6
6
  "codexmate": "./cli.js"
7
7
  },
8
8
  "scripts": {
9
9
  "start": "node cli.js",
10
+ "test": "npm run test:e2e",
10
11
  "test:e2e": "node tests/e2e/run.js"
11
12
  },
12
13
  "dependencies": {
@@ -0,0 +1,214 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const http = require('http');
4
+ const os = require('os');
5
+ const { spawnSync, spawn } = require('child_process');
6
+ const { writeJsonAtomic } = require('../../lib/cli-file-utils');
7
+ const { normalizeWireApi, buildModelProbeSpec } = require('../../lib/cli-models-utils');
8
+
9
+ const debug = (...args) => {
10
+ if (process.env.E2E_DEBUG) {
11
+ console.error('[e2e]', ...args);
12
+ }
13
+ };
14
+
15
+ function assert(condition, message) {
16
+ if (!condition) {
17
+ throw new Error(message);
18
+ }
19
+ }
20
+
21
+ function fileMode(filePath) {
22
+ return fs.existsSync(filePath) ? (fs.statSync(filePath).mode & 0o777) : 0;
23
+ }
24
+
25
+ function captureFileState(filePath) {
26
+ const state = {
27
+ path: filePath,
28
+ exists: false,
29
+ readable: true,
30
+ content: '',
31
+ error: ''
32
+ };
33
+
34
+ state.exists = fs.existsSync(filePath);
35
+ if (!state.exists) {
36
+ return state;
37
+ }
38
+
39
+ try {
40
+ state.content = fs.readFileSync(filePath, 'utf-8');
41
+ } catch (e) {
42
+ state.readable = false;
43
+ state.error = e && e.message ? e.message : String(e);
44
+ }
45
+ return state;
46
+ }
47
+
48
+ function assertFileUnchanged(state, label) {
49
+ if (!state || !state.readable) return;
50
+ const name = label || state.path;
51
+ if (state.exists) {
52
+ assert(fs.existsSync(state.path), `${name} disappeared during e2e`);
53
+ const current = fs.readFileSync(state.path, 'utf-8');
54
+ assert(current === state.content, `${name} changed during e2e`);
55
+ return;
56
+ }
57
+ assert(!fs.existsSync(state.path), `${name} should not be created during e2e`);
58
+ }
59
+
60
+ function runSync(node, args, options = {}) {
61
+ const result = spawnSync(node, args, {
62
+ encoding: 'utf-8',
63
+ ...options
64
+ });
65
+ return result;
66
+ }
67
+
68
+ function runWithInput(node, args, input, options = {}) {
69
+ return new Promise((resolve) => {
70
+ let child;
71
+ try {
72
+ child = spawn(node, args, { ...options, stdio: ['pipe', 'pipe', 'pipe'] });
73
+ } catch (err) {
74
+ return resolve({
75
+ status: 1,
76
+ stdout: '',
77
+ stderr: err && err.message ? err.message : String(err)
78
+ });
79
+ }
80
+ let stdout = '';
81
+ let stderr = '';
82
+ child.stdout.on('data', chunk => stdout += chunk.toString());
83
+ child.stderr.on('data', chunk => stderr += chunk.toString());
84
+ child.on('error', (err) => {
85
+ resolve({
86
+ status: 1,
87
+ stdout,
88
+ stderr: stderr || (err && err.message ? err.message : String(err))
89
+ });
90
+ });
91
+ child.on('close', (code) => resolve({ status: code, stdout, stderr }));
92
+ if (input) {
93
+ child.stdin.write(input);
94
+ }
95
+ child.stdin.end();
96
+ });
97
+ }
98
+
99
+ function postJson(port, payload, timeoutMs = 2000) {
100
+ return new Promise((resolve, reject) => {
101
+ const data = JSON.stringify(payload);
102
+ const req = http.request({
103
+ hostname: '127.0.0.1',
104
+ port,
105
+ path: '/api',
106
+ method: 'POST',
107
+ headers: {
108
+ 'Content-Type': 'application/json',
109
+ 'Content-Length': Buffer.byteLength(data)
110
+ }
111
+ }, (res) => {
112
+ let body = '';
113
+ res.setEncoding('utf-8');
114
+ res.on('data', chunk => body += chunk);
115
+ res.on('end', () => {
116
+ try {
117
+ resolve(JSON.parse(body || '{}'));
118
+ } catch (e) {
119
+ reject(new Error('Invalid JSON response'));
120
+ }
121
+ });
122
+ });
123
+
124
+ req.on('error', reject);
125
+ req.setTimeout(timeoutMs, () => {
126
+ req.destroy(new Error('Request timeout'));
127
+ });
128
+ req.write(data);
129
+ req.end();
130
+ });
131
+ }
132
+
133
+ async function waitForServer(port, retries = 20, delayMs = 200) {
134
+ let lastError;
135
+ for (let i = 0; i < retries; i++) {
136
+ try {
137
+ await postJson(port, { action: 'status' }, 1000);
138
+ return;
139
+ } catch (e) {
140
+ lastError = e;
141
+ debug(`wait retry ${i + 1}/${retries}: ${e && e.message ? e.message : e}`);
142
+ await new Promise(resolve => setTimeout(resolve, delayMs));
143
+ }
144
+ }
145
+ throw lastError || new Error('Server not ready');
146
+ }
147
+
148
+ function startLocalServer(options = {}) {
149
+ const mode = options.mode || 'list';
150
+ const modelsPath = options.modelsPath || '/models';
151
+ const status = options.status || 200;
152
+ return new Promise((resolve, reject) => {
153
+ const server = http.createServer((req, res) => {
154
+ if (req.url && req.url.startsWith(modelsPath)) {
155
+ if (mode === 'none') {
156
+ res.writeHead(404, { 'Content-Type': 'application/json' });
157
+ res.end(JSON.stringify({ error: 'not found' }));
158
+ return;
159
+ }
160
+ if (mode === 'html') {
161
+ res.writeHead(status, { 'Content-Type': 'text/html' });
162
+ res.end('<!doctype html><html><body>ok</body></html>');
163
+ return;
164
+ }
165
+ res.writeHead(status, { 'Content-Type': 'application/json' });
166
+ res.end(JSON.stringify({
167
+ data: [
168
+ { id: 'e2e2-model' },
169
+ { id: 'e2e2-model-2' }
170
+ ]
171
+ }));
172
+ return;
173
+ }
174
+ res.writeHead(status, { 'Content-Type': 'application/json' });
175
+ res.end(JSON.stringify({ ok: true }));
176
+ });
177
+ server.on('error', reject);
178
+ server.listen(0, '127.0.0.1', () => {
179
+ const address = server.address();
180
+ resolve({ server, port: address.port });
181
+ });
182
+ });
183
+ }
184
+
185
+ function closeServer(server) {
186
+ return new Promise((resolve) => {
187
+ if (!server) return resolve();
188
+ try {
189
+ server.close(() => resolve());
190
+ } catch (e) {
191
+ resolve();
192
+ }
193
+ });
194
+ }
195
+
196
+ module.exports = {
197
+ fs,
198
+ path,
199
+ os,
200
+ debug,
201
+ assert,
202
+ fileMode,
203
+ captureFileState,
204
+ assertFileUnchanged,
205
+ runSync,
206
+ runWithInput,
207
+ postJson,
208
+ waitForServer,
209
+ startLocalServer,
210
+ closeServer,
211
+ writeJsonAtomic,
212
+ normalizeWireApi,
213
+ buildModelProbeSpec
214
+ };
@@ -96,6 +96,12 @@ async function run() {
96
96
  await request('apply-config-template', { template: buildTemplate('gamma', 'm-gamma', 'https://example.com', 'sk-gamma') });
97
97
  await request('apply-config-template', { template: buildTemplate('delta', 'm-delta', 'https://example.com', 'sk-delta') });
98
98
 
99
+ const share = await request('export-provider', { name: 'delta' });
100
+ assert(share && share.payload, 'share payload should exist');
101
+ assert(share.payload.name === 'delta', 'share name should be delta');
102
+ assert(share.payload.baseUrl === 'https://example.com', 'share baseUrl mismatch');
103
+ assert(share.payload.apiKey === 'sk-delta', 'share apiKey mismatch');
104
+
99
105
  const recent = await request('get-recent-configs');
100
106
  assert(Array.isArray(recent.items), 'recent list should be array');
101
107
  assert(recent.items.length === 3, 'recent list should keep 3 items');
package/tests/e2e/run.js CHANGED
@@ -1,133 +1,34 @@
1
- const fs = require('fs');
2
- const os = require('os');
1
+ const { spawn } = require('child_process');
3
2
  const path = require('path');
4
- const http = require('http');
5
- const { spawnSync, spawn } = require('child_process');
6
-
7
- function assert(condition, message) {
8
- if (!condition) {
9
- throw new Error(message);
10
- }
11
- }
12
-
13
- function runSync(node, args, options = {}) {
14
- const result = spawnSync(node, args, {
15
- encoding: 'utf-8',
16
- ...options
17
- });
18
- return result;
19
- }
20
-
21
- function runWithInput(node, args, input, options = {}) {
22
- return new Promise((resolve) => {
23
- const child = spawn(node, args, { ...options, stdio: ['pipe', 'pipe', 'pipe'] });
24
- let stdout = '';
25
- let stderr = '';
26
- child.stdout.on('data', chunk => stdout += chunk.toString());
27
- child.stderr.on('data', chunk => stderr += chunk.toString());
28
- child.on('close', (code) => resolve({ status: code, stdout, stderr }));
29
- if (input) {
30
- child.stdin.write(input);
31
- }
32
- child.stdin.end();
33
- });
34
- }
35
- function postJson(port, payload, timeoutMs = 2000) {
36
- return new Promise((resolve, reject) => {
37
- const data = JSON.stringify(payload);
38
- const req = http.request({
39
- hostname: '127.0.0.1',
40
- port,
41
- path: '/api',
42
- method: 'POST',
43
- headers: {
44
- 'Content-Type': 'application/json',
45
- 'Content-Length': Buffer.byteLength(data)
46
- }
47
- }, (res) => {
48
- let body = '';
49
- res.setEncoding('utf-8');
50
- res.on('data', chunk => body += chunk);
51
- res.on('end', () => {
52
- try {
53
- resolve(JSON.parse(body || '{}'));
54
- } catch (e) {
55
- reject(new Error('Invalid JSON response'));
56
- }
57
- });
58
- });
59
-
60
- req.on('error', reject);
61
- req.setTimeout(timeoutMs, () => {
62
- req.destroy(new Error('Request timeout'));
63
- });
64
- req.write(data);
65
- req.end();
66
- });
67
- }
68
-
69
- async function waitForServer(port, retries = 20, delayMs = 200) {
70
- let lastError;
71
- for (let i = 0; i < retries; i++) {
72
- try {
73
- await postJson(port, { action: 'status' }, 1000);
74
- return;
75
- } catch (e) {
76
- lastError = e;
77
- await new Promise(resolve => setTimeout(resolve, delayMs));
78
- }
79
- }
80
- throw lastError || new Error('Server not ready');
81
- }
82
-
83
- function startLocalServer(options = {}) {
84
- const mode = options.mode || 'list';
85
- const modelsPath = options.modelsPath || '/models';
86
- return new Promise((resolve, reject) => {
87
- const server = http.createServer((req, res) => {
88
- if (req.url && req.url.startsWith(modelsPath)) {
89
- if (mode === 'none') {
90
- res.writeHead(404, { 'Content-Type': 'application/json' });
91
- res.end(JSON.stringify({ error: 'not found' }));
92
- return;
93
- }
94
- if (mode === 'html') {
95
- res.writeHead(200, { 'Content-Type': 'text/html' });
96
- res.end('<!doctype html><html><body>ok</body></html>');
97
- return;
98
- }
99
- res.writeHead(200, { 'Content-Type': 'application/json' });
100
- res.end(JSON.stringify({
101
- data: [
102
- { id: 'e2e2-model' },
103
- { id: 'e2e2-model-2' }
104
- ]
105
- }));
106
- return;
107
- }
108
- res.writeHead(200, { 'Content-Type': 'application/json' });
109
- res.end(JSON.stringify({ ok: true }));
110
- });
111
- server.on('error', reject);
112
- server.listen(0, '127.0.0.1', () => {
113
- const address = server.address();
114
- resolve({ server, port: address.port });
115
- });
116
- });
117
- }
118
-
119
- function closeServer(server) {
120
- return new Promise((resolve) => {
121
- if (!server) return resolve();
122
- try {
123
- server.close(() => resolve());
124
- } catch (e) {
125
- resolve();
126
- }
127
- });
128
- }
3
+ const fs = require('fs');
4
+ const {
5
+ os,
6
+ debug,
7
+ captureFileState,
8
+ assertFileUnchanged,
9
+ startLocalServer,
10
+ closeServer,
11
+ waitForServer,
12
+ postJson
13
+ } = require('./helpers');
14
+
15
+ const testSetup = require('./test-setup');
16
+ const testConfig = require('./test-config');
17
+ const testClaude = require('./test-claude');
18
+ const testSessions = require('./test-sessions');
19
+ const testOpenclaw = require('./test-openclaw');
20
+ const testHealthSpeed = require('./test-health-speed');
129
21
 
130
22
  async function main() {
23
+ const realHome = os.homedir();
24
+ const realCodexDir = path.join(realHome, '.codex');
25
+ const realFileStates = [
26
+ captureFileState(path.join(realCodexDir, 'config.toml')),
27
+ captureFileState(path.join(realCodexDir, 'auth.json')),
28
+ captureFileState(path.join(realCodexDir, 'models.json')),
29
+ captureFileState(path.join(realCodexDir, 'provider-current-models.json'))
30
+ ];
31
+
131
32
  const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'codexmate-e2e-'));
132
33
  const env = {
133
34
  ...process.env,
@@ -138,208 +39,89 @@ async function main() {
138
39
  const cliPath = path.resolve(__dirname, '../../cli.js');
139
40
  const node = process.execPath;
140
41
 
42
+ debug('setup start');
141
43
  let mockProvider;
142
44
  let noModelsProvider;
143
45
  let htmlModelsProvider;
46
+ let authFailProvider;
47
+ let webServer;
144
48
  try {
145
49
  mockProvider = await startLocalServer({ mode: 'list', modelsPath: '/v1/models' });
146
50
  noModelsProvider = await startLocalServer({ mode: 'none', modelsPath: '/v1/models' });
147
51
  htmlModelsProvider = await startLocalServer({ mode: 'html', modelsPath: '/v1/models' });
52
+ authFailProvider = await startLocalServer({ mode: 'list', modelsPath: '/v1/models', status: 401 });
53
+
148
54
  const mockProviderUrl = `http://127.0.0.1:${mockProvider.port}`;
149
55
  const noModelsUrl = `http://127.0.0.1:${noModelsProvider.port}`;
150
56
  const htmlModelsUrl = `http://127.0.0.1:${htmlModelsProvider.port}`;
57
+ const authFailUrl = `http://127.0.0.1:${authFailProvider.port}`;
151
58
 
152
- const setupInput = [
153
- '2',
154
- 'e2e',
59
+ const ctx = {
60
+ env,
61
+ node,
62
+ cliPath,
63
+ tmpHome,
155
64
  mockProviderUrl,
156
- 'sk-test',
157
- 'e2e-model',
158
- ''
159
- ].join('\n');
160
-
161
- const setupResult = await runWithInput(node, [cliPath, 'setup'], setupInput, { env });
65
+ noModelsUrl,
66
+ htmlModelsUrl,
67
+ authFailUrl
68
+ };
162
69
 
163
- assert(setupResult.status === 0, `setup failed: ${setupResult.stderr || setupResult.stdout}`);
164
-
165
- const configPath = path.join(tmpHome, '.codex', 'config.toml');
166
- assert(fs.existsSync(configPath), 'config.toml missing');
167
- const configContent = fs.readFileSync(configPath, 'utf-8');
168
- assert(/model_provider\s*=\s*"e2e"/.test(configContent), 'model_provider not set');
169
- assert(/model\s*=\s*"e2e-model"/.test(configContent), 'model not set');
170
- assert(/\[model_providers\.e2e\]/.test(configContent), 'provider block missing');
171
- assert(/base_url\s*=\s*"http:\/\/127\.0\.0\.1:\d+"/.test(configContent), 'base_url missing');
172
-
173
- const authPath = path.join(tmpHome, '.codex', 'auth.json');
174
- const auth = JSON.parse(fs.readFileSync(authPath, 'utf-8'));
175
- assert(auth.OPENAI_API_KEY === 'sk-test', 'auth api_key mismatch');
176
-
177
- const modelsPath = path.join(tmpHome, '.codex', 'models.json');
178
- const models = JSON.parse(fs.readFileSync(modelsPath, 'utf-8'));
179
- assert(models.includes('e2e-model'), 'custom model not added');
180
-
181
- const sessionsDir = path.join(tmpHome, '.codex', 'sessions');
182
- fs.mkdirSync(sessionsDir, { recursive: true });
183
- const sessionId = 'e2e-session';
184
- const sessionPath = path.join(sessionsDir, `${sessionId}.jsonl`);
185
- const sessionRecords = [
186
- {
187
- type: 'session_meta',
188
- payload: { id: sessionId, cwd: '/tmp/e2e' },
189
- timestamp: '2025-01-01T00:00:00.000Z'
190
- },
191
- {
192
- type: 'response_item',
193
- payload: { type: 'message', role: 'user', content: 'hello' },
194
- timestamp: '2025-01-01T00:00:01.000Z'
195
- },
196
- {
197
- type: 'response_item',
198
- payload: { type: 'message', role: 'assistant', content: 'world' },
199
- timestamp: '2025-01-01T00:00:02.000Z'
200
- }
201
- ];
202
- fs.writeFileSync(sessionPath, sessionRecords.map(record => JSON.stringify(record)).join('\n') + '\n', 'utf-8');
203
-
204
- const statusResult = runSync(node, [cliPath, 'status'], { env });
205
- assert(statusResult.status === 0, 'status failed');
206
- assert(statusResult.stdout.includes('提供商: e2e'), 'status provider not shown');
207
- assert(statusResult.stdout.includes('模型: e2e-model'), 'status model not shown');
208
-
209
- const listResult = runSync(node, [cliPath, 'list'], { env });
210
- assert(listResult.status === 0, 'list failed');
211
- assert(listResult.stdout.includes('e2e'), 'list missing provider');
70
+ await testSetup(ctx);
212
71
 
213
72
  const port = 18000 + Math.floor(Math.random() * 1000);
214
- const webServer = spawn(node, [cliPath, 'start'], {
73
+ debug('start web server');
74
+ webServer = spawn(node, [cliPath, 'run'], {
215
75
  env: { ...env, CODEXMATE_PORT: String(port) },
216
76
  stdio: ['ignore', 'pipe', 'pipe']
217
77
  });
218
78
  webServer.stdout.on('data', () => {});
219
79
  webServer.stderr.on('data', () => {});
220
80
 
221
- try {
222
- await waitForServer(port);
223
- const apiStatus = await postJson(port, { action: 'status' });
224
- assert(apiStatus.provider === 'e2e', 'api status provider mismatch');
225
-
226
- const apiList = await postJson(port, { action: 'list' });
227
- assert(Array.isArray(apiList.providers), 'api list missing providers');
228
- assert(apiList.providers.some(p => p.name === 'e2e'), 'api list missing provider');
229
-
230
- const exportResult = await postJson(port, { action: 'export-config', params: { includeKeys: true } });
231
- assert(exportResult.data, 'export-config missing data');
232
- assert(exportResult.data.providers && exportResult.data.providers.e2e, 'export-config missing provider');
233
- assert(exportResult.data.providers.e2e.apiKey === 'sk-test', 'export-config apiKey mismatch');
234
-
235
- const importPayload = JSON.parse(JSON.stringify(exportResult.data));
236
- importPayload.providers = {
237
- ...importPayload.providers,
238
- e2e2: { baseUrl: mockProviderUrl, apiKey: 'sk-e2e2' },
239
- e2e3: { baseUrl: noModelsUrl, apiKey: 'sk-e2e3' },
240
- e2e4: { baseUrl: htmlModelsUrl, apiKey: 'sk-e2e4' }
241
- };
242
- importPayload.models = Array.from(new Set([...(importPayload.models || []), 'e2e2-model']));
243
- importPayload.currentProvider = 'e2e2';
244
- importPayload.currentModel = 'e2e2-model';
245
- importPayload.currentModels = { ...(importPayload.currentModels || {}), e2e2: 'e2e2-model' };
246
-
247
- const importResult = await postJson(port, {
248
- action: 'import-config',
249
- params: {
250
- payload: importPayload,
251
- options: { overwriteProviders: true, applyCurrent: true, applyCurrentModels: true }
252
- }
253
- });
254
- assert(importResult.success === true, 'import-config failed');
255
-
256
- const apiStatusAfter = await postJson(port, { action: 'status' });
257
- assert(apiStatusAfter.provider === 'e2e2', 'api status provider after import mismatch');
258
- assert(apiStatusAfter.model === 'e2e2-model', 'api status model after import mismatch');
259
-
260
- const apiModels = await postJson(port, { action: 'models', params: { provider: 'e2e2' } });
261
- assert(Array.isArray(apiModels.models) && apiModels.models.includes('e2e2-model-2'), 'api models missing remote entry');
81
+ await waitForServer(port);
82
+ debug('server ready');
262
83
 
263
- const apiModelsUnlimited = await postJson(port, { action: 'models', params: { provider: 'e2e3' } });
264
- assert(apiModelsUnlimited.unlimited === true, 'api models unlimited missing');
84
+ const api = (action, params, timeoutMs) => postJson(port, { action, params }, timeoutMs);
85
+ Object.assign(ctx, { port, api });
265
86
 
266
- const apiModelsHtml = await postJson(port, { action: 'models', params: { provider: 'e2e4' } });
267
- assert(apiModelsHtml.unlimited === true, 'api models html unlimited missing');
87
+ await testConfig(ctx);
88
+ await testClaude(ctx);
89
+ await testSessions(ctx);
90
+ await testOpenclaw(ctx);
91
+ await testHealthSpeed(ctx);
268
92
 
269
- const apiModelsByUrl = await postJson(port, {
270
- action: 'models-by-url',
271
- params: { baseUrl: mockProviderUrl, apiKey: 'sk-e2e2' }
272
- });
273
- assert(Array.isArray(apiModelsByUrl.models) && apiModelsByUrl.models.includes('e2e2-model'), 'api models-by-url missing remote entry');
274
-
275
- const apiModelsByUrlUnlimited = await postJson(port, {
276
- action: 'models-by-url',
277
- params: { baseUrl: noModelsUrl }
278
- });
279
- assert(apiModelsByUrlUnlimited.unlimited === true, 'api models-by-url unlimited missing');
280
-
281
- const apiSessions = await postJson(port, {
282
- action: 'list-sessions',
283
- params: { source: 'codex', limit: 50, forceRefresh: true }
284
- });
285
- assert(Array.isArray(apiSessions.sessions), 'api sessions missing');
286
- assert(apiSessions.sessions.some(item => item.sessionId === sessionId), 'api sessions missing codex entry');
287
-
288
- const cloneResult = await postJson(port, {
289
- action: 'clone-session',
290
- params: { source: 'codex', sessionId }
291
- });
292
- assert(cloneResult.success === true, 'clone-session failed');
293
- assert(cloneResult.sessionId && cloneResult.sessionId !== sessionId, 'clone-session id invalid');
294
- assert(fs.existsSync(cloneResult.filePath), 'clone-session file missing');
295
-
296
- const apiSessionsAfterClone = await postJson(port, {
297
- action: 'list-sessions',
298
- params: { source: 'codex', limit: 50, forceRefresh: true }
299
- });
300
- assert(Array.isArray(apiSessionsAfterClone.sessions), 'api sessions after clone missing');
301
- assert(
302
- apiSessionsAfterClone.sessions[0]
303
- && apiSessionsAfterClone.sessions[0].sessionId === cloneResult.sessionId,
304
- 'clone session not latest'
305
- );
306
-
307
- const deleteResult = await postJson(port, {
308
- action: 'delete-session',
309
- params: { source: 'codex', sessionId }
310
- });
311
- assert(deleteResult.success === true, 'delete-session failed');
312
- assert(!fs.existsSync(sessionPath), 'delete-session file still exists');
313
-
314
- const apiSessionsAfterDelete = await postJson(port, {
315
- action: 'list-sessions',
316
- params: { source: 'codex', limit: 50, forceRefresh: true }
93
+ } finally {
94
+ const waitForExit = new Promise((resolve) => {
95
+ if (!webServer) return resolve();
96
+ const forceKill = setTimeout(() => {
97
+ try {
98
+ webServer.kill('SIGKILL');
99
+ } catch (e) {}
100
+ }, 2000);
101
+ webServer.once('exit', () => {
102
+ clearTimeout(forceKill);
103
+ resolve();
317
104
  });
318
- assert(!apiSessionsAfterDelete.sessions.some(item => item.sessionId === sessionId), 'deleted session still listed');
319
- assert(apiSessionsAfterDelete.sessions.some(item => item.sessionId === cloneResult.sessionId), 'clone session missing after delete');
320
-
321
- const speedResult = await postJson(port, { action: 'speed-test', params: { name: 'e2e2' } }, 4000);
322
- assert(speedResult.ok === true, 'speed-test failed');
105
+ if (webServer.exitCode !== null || webServer.signalCode) {
106
+ clearTimeout(forceKill);
107
+ resolve();
108
+ }
109
+ });
110
+ try {
111
+ if (webServer) {
112
+ webServer.kill('SIGINT');
113
+ }
114
+ } catch (e) {}
115
+ await waitForExit;
323
116
 
324
- const switchResult = runSync(node, [cliPath, 'switch', 'e2e4'], { env });
325
- assert(switchResult.status === 0, 'cli switch failed');
117
+ await closeServer(mockProvider && mockProvider.server);
118
+ await closeServer(noModelsProvider && noModelsProvider.server);
119
+ await closeServer(htmlModelsProvider && htmlModelsProvider.server);
120
+ await closeServer(authFailProvider && authFailProvider.server);
326
121
 
327
- const cliModels = await runWithInput(node, [cliPath, 'models'], '', { env });
328
- assert(cliModels.status === 0, 'cli models failed');
329
- assert(cliModels.stdout.includes('视为不限'), 'cli models missing unlimited hint');
330
- } finally {
331
- webServer.kill('SIGINT');
332
- await new Promise(resolve => webServer.on('exit', resolve));
333
- }
334
- } finally {
335
- if (mockProvider) {
336
- await closeServer(mockProvider.server);
337
- }
338
- if (noModelsProvider) {
339
- await closeServer(noModelsProvider.server);
340
- }
341
- if (htmlModelsProvider) {
342
- await closeServer(htmlModelsProvider.server);
122
+ for (const state of realFileStates) {
123
+ const label = state && state.path ? path.basename(state.path) : 'real file';
124
+ assertFileUnchanged(state, `real ${label}`);
343
125
  }
344
126
  try {
345
127
  if (fs.rmSync) {