codexmate 0.0.7 → 0.0.9

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.
Files changed (47) hide show
  1. package/.github/workflows/release.yml +122 -8
  2. package/.planning/.fix-attempts +1 -0
  3. package/.planning/.lock +6 -0
  4. package/.planning/.verify-cache.json +14 -0
  5. package/.planning/CHECKPOINT.json +46 -0
  6. package/.planning/DESIGN.md +26 -0
  7. package/.planning/HISTORY.json +124 -0
  8. package/.planning/PLAN.md +69 -0
  9. package/.planning/REVIEW.md +41 -0
  10. package/.planning/STATE.md +12 -0
  11. package/.planning/STATS.json +13 -0
  12. package/.planning/VERIFICATION.md +70 -0
  13. package/.planning/daude-code-plan.md +51 -0
  14. package/.planning/research/architecture.md +32 -0
  15. package/.planning/research/conventions.md +36 -0
  16. package/.planning/task_1-REVIEW.md +29 -0
  17. package/.planning/task_1-SUMMARY.md +32 -0
  18. package/.planning/task_2-REVIEW.md +24 -0
  19. package/.planning/task_2-SUMMARY.md +37 -0
  20. package/.planning/task_3-REVIEW.md +25 -0
  21. package/.planning/task_3-SUMMARY.md +31 -0
  22. package/README.md +58 -52
  23. package/README.zh-CN.md +68 -56
  24. package/cli.js +1142 -1427
  25. package/lib/cli-file-utils.js +151 -0
  26. package/lib/cli-models-utils.js +152 -0
  27. package/lib/cli-network-utils.js +148 -0
  28. package/lib/cli-session-utils.js +121 -0
  29. package/lib/cli-utils.js +139 -0
  30. package/package.json +4 -2
  31. package/res/json5.min.js +1 -0
  32. package/res/vue.global.js +18552 -0
  33. package/tests/e2e/helpers.js +214 -0
  34. package/tests/e2e/recent-health.e2e.js +6 -0
  35. package/tests/e2e/run.js +103 -306
  36. package/tests/e2e/test-claude.js +21 -0
  37. package/tests/e2e/test-config.js +124 -0
  38. package/tests/e2e/test-health-speed.js +79 -0
  39. package/tests/e2e/test-openclaw.js +47 -0
  40. package/tests/e2e/test-session-search.js +114 -0
  41. package/tests/e2e/test-sessions.js +69 -0
  42. package/tests/e2e/test-setup.js +159 -0
  43. package/tests/unit/run.mjs +29 -0
  44. package/tests/unit/web-ui-logic.test.mjs +186 -0
  45. package/web-ui/app.js +2841 -0
  46. package/web-ui/logic.mjs +157 -0
  47. package/web-ui.html +1045 -2996
@@ -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,35 @@
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 testSessionSearch = require('./test-session-search');
19
+ const testSessions = require('./test-sessions');
20
+ const testOpenclaw = require('./test-openclaw');
21
+ const testHealthSpeed = require('./test-health-speed');
129
22
 
130
23
  async function main() {
24
+ const realHome = os.homedir();
25
+ const realCodexDir = path.join(realHome, '.codex');
26
+ const realFileStates = [
27
+ captureFileState(path.join(realCodexDir, 'config.toml')),
28
+ captureFileState(path.join(realCodexDir, 'auth.json')),
29
+ captureFileState(path.join(realCodexDir, 'models.json')),
30
+ captureFileState(path.join(realCodexDir, 'provider-current-models.json'))
31
+ ];
32
+
131
33
  const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'codexmate-e2e-'));
132
34
  const env = {
133
35
  ...process.env,
@@ -138,208 +40,103 @@ async function main() {
138
40
  const cliPath = path.resolve(__dirname, '../../cli.js');
139
41
  const node = process.execPath;
140
42
 
43
+ debug('setup start');
141
44
  let mockProvider;
142
45
  let noModelsProvider;
143
46
  let htmlModelsProvider;
47
+ let authFailProvider;
48
+ let webServer;
144
49
  try {
145
50
  mockProvider = await startLocalServer({ mode: 'list', modelsPath: '/v1/models' });
146
51
  noModelsProvider = await startLocalServer({ mode: 'none', modelsPath: '/v1/models' });
147
52
  htmlModelsProvider = await startLocalServer({ mode: 'html', modelsPath: '/v1/models' });
53
+ authFailProvider = await startLocalServer({ mode: 'list', modelsPath: '/v1/models', status: 401 });
54
+
148
55
  const mockProviderUrl = `http://127.0.0.1:${mockProvider.port}`;
149
56
  const noModelsUrl = `http://127.0.0.1:${noModelsProvider.port}`;
150
57
  const htmlModelsUrl = `http://127.0.0.1:${htmlModelsProvider.port}`;
58
+ const authFailUrl = `http://127.0.0.1:${authFailProvider.port}`;
151
59
 
152
- const setupInput = [
153
- '2',
154
- 'e2e',
60
+ const ctx = {
61
+ env,
62
+ node,
63
+ cliPath,
64
+ tmpHome,
155
65
  mockProviderUrl,
156
- 'sk-test',
157
- 'e2e-model',
158
- ''
159
- ].join('\n');
160
-
161
- const setupResult = await runWithInput(node, [cliPath, 'setup'], setupInput, { env });
162
-
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');
66
+ noModelsUrl,
67
+ htmlModelsUrl,
68
+ authFailUrl
69
+ };
70
+
71
+ await testSetup(ctx);
72
+ if (ctx.skipE2E) {
73
+ console.warn(`E2E skipped: ${ctx.skipE2E}`);
74
+ return;
75
+ }
212
76
 
213
77
  const port = 18000 + Math.floor(Math.random() * 1000);
214
- const webServer = spawn(node, [cliPath, 'start'], {
215
- env: { ...env, CODEXMATE_PORT: String(port) },
216
- stdio: ['ignore', 'pipe', 'pipe']
217
- });
218
- webServer.stdout.on('data', () => {});
219
- webServer.stderr.on('data', () => {});
220
-
78
+ debug('start web server');
221
79
  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');
262
-
263
- const apiModelsUnlimited = await postJson(port, { action: 'models', params: { provider: 'e2e3' } });
264
- assert(apiModelsUnlimited.unlimited === true, 'api models unlimited missing');
265
-
266
- const apiModelsHtml = await postJson(port, { action: 'models', params: { provider: 'e2e4' } });
267
- assert(apiModelsHtml.unlimited === true, 'api models html unlimited missing');
268
-
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 }
80
+ webServer = spawn(node, [cliPath, 'run'], {
81
+ env: { ...env, CODEXMATE_PORT: String(port) },
82
+ stdio: ['ignore', 'pipe', 'pipe']
284
83
  });
285
- assert(Array.isArray(apiSessions.sessions), 'api sessions missing');
286
- assert(apiSessions.sessions.some(item => item.sessionId === sessionId), 'api sessions missing codex entry');
84
+ } catch (err) {
85
+ if (err && err.code === 'EPERM') {
86
+ console.warn('E2E skipped: child_process spawn blocked (EPERM) when starting server');
87
+ ctx.skipE2E = ctx.skipE2E || 'child_process spawn blocked (EPERM) when starting server';
88
+ return;
89
+ }
90
+ throw err;
91
+ }
92
+ webServer.stdout.on('data', () => {});
93
+ webServer.stderr.on('data', () => {});
287
94
 
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');
95
+ await waitForServer(port);
96
+ debug('server ready');
295
97
 
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
- );
98
+ const api = (action, params, timeoutMs) => postJson(port, { action, params }, timeoutMs);
99
+ Object.assign(ctx, { port, api });
306
100
 
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');
101
+ await testConfig(ctx);
102
+ await testClaude(ctx);
103
+ await testSessionSearch(ctx);
104
+ await testSessions(ctx);
105
+ await testOpenclaw(ctx);
106
+ await testHealthSpeed(ctx);
313
107
 
314
- const apiSessionsAfterDelete = await postJson(port, {
315
- action: 'list-sessions',
316
- params: { source: 'codex', limit: 50, forceRefresh: true }
108
+ } finally {
109
+ const waitForExit = new Promise((resolve) => {
110
+ if (!webServer) return resolve();
111
+ const forceKill = setTimeout(() => {
112
+ try {
113
+ webServer.kill('SIGKILL');
114
+ } catch (e) {}
115
+ }, 2000);
116
+ webServer.once('exit', () => {
117
+ clearTimeout(forceKill);
118
+ resolve();
317
119
  });
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');
120
+ if (webServer.exitCode !== null || webServer.signalCode) {
121
+ clearTimeout(forceKill);
122
+ resolve();
123
+ }
124
+ });
125
+ try {
126
+ if (webServer) {
127
+ webServer.kill('SIGINT');
128
+ }
129
+ } catch (e) {}
130
+ await waitForExit;
323
131
 
324
- const switchResult = runSync(node, [cliPath, 'switch', 'e2e4'], { env });
325
- assert(switchResult.status === 0, 'cli switch failed');
132
+ await closeServer(mockProvider && mockProvider.server);
133
+ await closeServer(noModelsProvider && noModelsProvider.server);
134
+ await closeServer(htmlModelsProvider && htmlModelsProvider.server);
135
+ await closeServer(authFailProvider && authFailProvider.server);
326
136
 
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);
137
+ for (const state of realFileStates) {
138
+ const label = state && state.path ? path.basename(state.path) : 'real file';
139
+ assertFileUnchanged(state, `real ${label}`);
343
140
  }
344
141
  try {
345
142
  if (fs.rmSync) {