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.
- package/.github/workflows/release.yml +122 -8
- package/.planning/.fix-attempts +1 -0
- package/.planning/.lock +6 -0
- package/.planning/.verify-cache.json +14 -0
- package/.planning/CHECKPOINT.json +46 -0
- package/.planning/DESIGN.md +26 -0
- package/.planning/HISTORY.json +124 -0
- package/.planning/PLAN.md +69 -0
- package/.planning/REVIEW.md +41 -0
- package/.planning/STATE.md +12 -0
- package/.planning/STATS.json +13 -0
- package/.planning/VERIFICATION.md +70 -0
- package/.planning/daude-code-plan.md +51 -0
- package/.planning/research/architecture.md +32 -0
- package/.planning/research/conventions.md +36 -0
- package/.planning/task_1-REVIEW.md +29 -0
- package/.planning/task_1-SUMMARY.md +32 -0
- package/.planning/task_2-REVIEW.md +24 -0
- package/.planning/task_2-SUMMARY.md +37 -0
- package/.planning/task_3-REVIEW.md +25 -0
- package/.planning/task_3-SUMMARY.md +31 -0
- package/README.md +58 -52
- package/README.zh-CN.md +68 -56
- package/cli.js +1142 -1427
- package/lib/cli-file-utils.js +151 -0
- package/lib/cli-models-utils.js +152 -0
- package/lib/cli-network-utils.js +148 -0
- package/lib/cli-session-utils.js +121 -0
- package/lib/cli-utils.js +139 -0
- package/package.json +4 -2
- package/res/json5.min.js +1 -0
- package/res/vue.global.js +18552 -0
- package/tests/e2e/helpers.js +214 -0
- package/tests/e2e/recent-health.e2e.js +6 -0
- package/tests/e2e/run.js +103 -306
- package/tests/e2e/test-claude.js +21 -0
- package/tests/e2e/test-config.js +124 -0
- package/tests/e2e/test-health-speed.js +79 -0
- package/tests/e2e/test-openclaw.js +47 -0
- package/tests/e2e/test-session-search.js +114 -0
- package/tests/e2e/test-sessions.js +69 -0
- package/tests/e2e/test-setup.js +159 -0
- package/tests/unit/run.mjs +29 -0
- package/tests/unit/web-ui-logic.test.mjs +186 -0
- package/web-ui/app.js +2841 -0
- package/web-ui/logic.mjs +157 -0
- package/web-ui.html +1045 -2996
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
const { assert } = require('./helpers');
|
|
2
|
+
|
|
3
|
+
module.exports = async function testClaude(ctx) {
|
|
4
|
+
const { api, mockProviderUrl, claudeModel } = ctx;
|
|
5
|
+
|
|
6
|
+
const claudeSettingsInfo = await api('get-claude-settings');
|
|
7
|
+
assert(claudeSettingsInfo.apiKey === 'sk-claude', 'get-claude-settings apiKey mismatch');
|
|
8
|
+
assert(claudeSettingsInfo.baseUrl === mockProviderUrl, 'get-claude-settings baseUrl mismatch');
|
|
9
|
+
assert(claudeSettingsInfo.model === claudeModel, 'get-claude-settings model mismatch');
|
|
10
|
+
|
|
11
|
+
const claudeShareMissing = await api('export-claude-share', { config: { apiKey: 'only-key' } });
|
|
12
|
+
assert(claudeShareMissing.error, 'export-claude-share should fail when baseUrl missing');
|
|
13
|
+
|
|
14
|
+
const claudeShare = await api('export-claude-share', {
|
|
15
|
+
config: { baseUrl: mockProviderUrl, apiKey: 'sk-claude', model: claudeModel }
|
|
16
|
+
});
|
|
17
|
+
assert(claudeShare.payload, 'export-claude-share missing payload');
|
|
18
|
+
assert(claudeShare.payload.baseUrl === mockProviderUrl, 'export-claude-share baseUrl mismatch');
|
|
19
|
+
assert(claudeShare.payload.apiKey === 'sk-claude', 'export-claude-share apiKey mismatch');
|
|
20
|
+
assert(claudeShare.payload.model === claudeModel, 'export-claude-share model mismatch');
|
|
21
|
+
};
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
const { assert } = require('./helpers');
|
|
2
|
+
|
|
3
|
+
module.exports = async function testConfig(ctx) {
|
|
4
|
+
const {
|
|
5
|
+
api,
|
|
6
|
+
mockProviderUrl,
|
|
7
|
+
noModelsUrl,
|
|
8
|
+
htmlModelsUrl,
|
|
9
|
+
authFailUrl
|
|
10
|
+
} = ctx;
|
|
11
|
+
|
|
12
|
+
const apiStatus = await api('status');
|
|
13
|
+
assert(apiStatus.provider === 'e2e', 'api status provider mismatch');
|
|
14
|
+
|
|
15
|
+
const apiList = await api('list');
|
|
16
|
+
assert(Array.isArray(apiList.providers), 'api list missing providers');
|
|
17
|
+
assert(apiList.providers.some(p => p.name === 'e2e'), 'api list missing provider');
|
|
18
|
+
|
|
19
|
+
const templateOverride = await api('get-config-template', {
|
|
20
|
+
provider: 'shadow',
|
|
21
|
+
model: 'shadow-model',
|
|
22
|
+
serviceTier: 'fast'
|
|
23
|
+
});
|
|
24
|
+
assert(typeof templateOverride.template === 'string', 'get-config-template missing template');
|
|
25
|
+
assert(/^\s*service_tier\s*=\s*"fast"\s*$/m.test(templateOverride.template), 'get-config-template missing service_tier');
|
|
26
|
+
assert(templateOverride.template.includes('model_provider = "shadow"'), 'get-config-template missing provider override');
|
|
27
|
+
assert(templateOverride.template.includes('model = "shadow-model"'), 'get-config-template missing model override');
|
|
28
|
+
|
|
29
|
+
const templateStandard = await api('get-config-template', {
|
|
30
|
+
provider: 'shadow',
|
|
31
|
+
model: 'shadow-model',
|
|
32
|
+
serviceTier: 'standard'
|
|
33
|
+
});
|
|
34
|
+
assert(typeof templateStandard.template === 'string', 'get-config-template(standard) missing template');
|
|
35
|
+
assert(!/\s*service_tier\s*=/.test(templateStandard.template), 'get-config-template(standard) should not include service_tier');
|
|
36
|
+
|
|
37
|
+
const exportResult = await api('export-config', { includeKeys: true });
|
|
38
|
+
assert(exportResult.data, 'export-config missing data');
|
|
39
|
+
assert(exportResult.data.providers && exportResult.data.providers.e2e, 'export-config missing provider');
|
|
40
|
+
assert(exportResult.data.providers.e2e.apiKey === 'sk-test', 'export-config apiKey mismatch');
|
|
41
|
+
|
|
42
|
+
const exportNoKeys = await api('export-config', { includeKeys: false });
|
|
43
|
+
assert(exportNoKeys.data, 'export-config(no keys) missing data');
|
|
44
|
+
assert(exportNoKeys.data.providers && exportNoKeys.data.providers.e2e, 'export-config(no keys) missing provider');
|
|
45
|
+
assert(exportNoKeys.data.providers.e2e.apiKey === null, 'export-config(no keys) apiKey should be null');
|
|
46
|
+
|
|
47
|
+
const importInvalid = await api('import-config', { payload: null });
|
|
48
|
+
assert(importInvalid.error && importInvalid.error.includes('Invalid import payload'), 'import-config should reject invalid payload');
|
|
49
|
+
|
|
50
|
+
const modelsMissing = await api('models', { provider: 'missing' });
|
|
51
|
+
assert(modelsMissing.error, 'models should fail for missing provider');
|
|
52
|
+
|
|
53
|
+
const modelsByUrlInvalid = await api('models-by-url', { baseUrl: 'not-a-url' });
|
|
54
|
+
assert(modelsByUrlInvalid.error, 'models-by-url should fail for invalid url');
|
|
55
|
+
|
|
56
|
+
const applyEmpty = await api('apply-config-template', { template: '' });
|
|
57
|
+
assert(applyEmpty.error, 'apply-config-template should reject empty template');
|
|
58
|
+
|
|
59
|
+
const applyNoProvider = await api('apply-config-template', {
|
|
60
|
+
template: 'model = "x"\n[model_providers.x]\nbase_url = "http://example.com"\n'
|
|
61
|
+
});
|
|
62
|
+
assert(applyNoProvider.error, 'apply-config-template should require model_provider');
|
|
63
|
+
|
|
64
|
+
const applyNoModel = await api('apply-config-template', {
|
|
65
|
+
template: 'model_provider = "x"\n[model_providers.x]\nbase_url = "http://example.com"\n'
|
|
66
|
+
});
|
|
67
|
+
assert(applyNoModel.error, 'apply-config-template should require model');
|
|
68
|
+
|
|
69
|
+
const applyNoProviders = await api('apply-config-template', {
|
|
70
|
+
template: 'model_provider = "x"\nmodel = "y"\n'
|
|
71
|
+
});
|
|
72
|
+
assert(applyNoProviders.error, 'apply-config-template should require model_providers');
|
|
73
|
+
|
|
74
|
+
const importPayload = JSON.parse(JSON.stringify(exportResult.data));
|
|
75
|
+
importPayload.providers = {
|
|
76
|
+
...importPayload.providers,
|
|
77
|
+
e2e2: { baseUrl: mockProviderUrl, apiKey: 'sk-e2e2' },
|
|
78
|
+
e2e3: { baseUrl: noModelsUrl, apiKey: 'sk-e2e3' },
|
|
79
|
+
e2e4: { baseUrl: htmlModelsUrl, apiKey: 'sk-e2e4' }
|
|
80
|
+
};
|
|
81
|
+
importPayload.models = Array.from(new Set([...(importPayload.models || []), 'e2e2-model']));
|
|
82
|
+
importPayload.currentProvider = 'e2e2';
|
|
83
|
+
importPayload.currentModel = 'e2e2-model';
|
|
84
|
+
importPayload.currentModels = { ...(importPayload.currentModels || {}), e2e2: 'e2e2-model' };
|
|
85
|
+
|
|
86
|
+
const importResult = await api('import-config', {
|
|
87
|
+
payload: importPayload,
|
|
88
|
+
options: { overwriteProviders: true, applyCurrent: true, applyCurrentModels: true }
|
|
89
|
+
});
|
|
90
|
+
assert(importResult.success === true, 'import-config failed');
|
|
91
|
+
|
|
92
|
+
const exportProviderMissing = await api('export-provider', { name: 'ghost' });
|
|
93
|
+
assert(exportProviderMissing.error, 'export-provider should fail for missing provider');
|
|
94
|
+
|
|
95
|
+
const exportProvider = await api('export-provider', { name: 'e2e2' });
|
|
96
|
+
assert(exportProvider.payload, 'export-provider missing payload');
|
|
97
|
+
assert(exportProvider.payload.baseUrl === mockProviderUrl, 'export-provider baseUrl mismatch');
|
|
98
|
+
assert(exportProvider.payload.apiKey === 'sk-e2e2', 'export-provider apiKey mismatch');
|
|
99
|
+
|
|
100
|
+
const apiStatusAfter = await api('status');
|
|
101
|
+
assert(apiStatusAfter.provider === 'e2e2', 'api status provider after import mismatch');
|
|
102
|
+
assert(apiStatusAfter.model === 'e2e2-model', 'api status model after import mismatch');
|
|
103
|
+
|
|
104
|
+
const apiModels = await api('models', { provider: 'e2e2' });
|
|
105
|
+
assert(Array.isArray(apiModels.models) && apiModels.models.includes('e2e2-model-2'), 'api models missing remote entry');
|
|
106
|
+
|
|
107
|
+
const apiModelsUnlimited = await api('models', { provider: 'e2e3' });
|
|
108
|
+
assert(apiModelsUnlimited.unlimited === true, 'api models unlimited missing');
|
|
109
|
+
|
|
110
|
+
const apiModelsHtml = await api('models', { provider: 'e2e4' });
|
|
111
|
+
assert(apiModelsHtml.unlimited === true, 'api models html unlimited missing');
|
|
112
|
+
|
|
113
|
+
const apiModelsByUrl = await api('models-by-url', { baseUrl: mockProviderUrl, apiKey: 'sk-e2e2' });
|
|
114
|
+
assert(Array.isArray(apiModelsByUrl.models) && apiModelsByUrl.models.includes('e2e2-model'), 'api models-by-url missing remote entry');
|
|
115
|
+
|
|
116
|
+
const apiModelsByUrlUnlimited = await api('models-by-url', { baseUrl: noModelsUrl });
|
|
117
|
+
assert(apiModelsByUrlUnlimited.unlimited === true, 'api models-by-url unlimited missing');
|
|
118
|
+
|
|
119
|
+
const apiPaths = await api('list-session-paths', { source: 'codex', limit: 10, forceRefresh: true });
|
|
120
|
+
assert(Array.isArray(apiPaths.paths), 'api session paths missing');
|
|
121
|
+
assert(apiPaths.paths.includes('/tmp/e2e'), 'api session paths missing cwd');
|
|
122
|
+
|
|
123
|
+
ctx.importPayload = importPayload;
|
|
124
|
+
};
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
const path = require('path');
|
|
2
|
+
const {
|
|
3
|
+
assert,
|
|
4
|
+
normalizeWireApi,
|
|
5
|
+
buildModelProbeSpec,
|
|
6
|
+
fileMode,
|
|
7
|
+
writeJsonAtomic
|
|
8
|
+
} = require('./helpers');
|
|
9
|
+
|
|
10
|
+
module.exports = async function testHealthAndSpeed(ctx) {
|
|
11
|
+
const { api, mockProviderUrl, authFailUrl, tmpHome } = ctx;
|
|
12
|
+
|
|
13
|
+
const speedResult = await api('speed-test', { name: 'e2e2' });
|
|
14
|
+
assert(speedResult.ok === true, 'speed-test failed');
|
|
15
|
+
|
|
16
|
+
const configPath = path.join(tmpHome, '.codex', 'config.toml');
|
|
17
|
+
const originalConfig = require('fs').readFileSync(configPath, 'utf-8');
|
|
18
|
+
try {
|
|
19
|
+
const invalidConfig = [
|
|
20
|
+
'model_provider = "bad"',
|
|
21
|
+
'model = "missing"',
|
|
22
|
+
'',
|
|
23
|
+
'[model_providers.bad]',
|
|
24
|
+
'base_url = "not-a-url"',
|
|
25
|
+
'preferred_auth_method = "sk-bad"',
|
|
26
|
+
''
|
|
27
|
+
].join('\n');
|
|
28
|
+
require('fs').writeFileSync(configPath, invalidConfig, 'utf-8');
|
|
29
|
+
|
|
30
|
+
const healthInvalid = await api('config-health-check', { remote: false });
|
|
31
|
+
assert(healthInvalid.ok === false, 'health-check should fail for invalid base_url');
|
|
32
|
+
assert(
|
|
33
|
+
Array.isArray(healthInvalid.issues) &&
|
|
34
|
+
healthInvalid.issues.some(issue => issue.code === 'base-url-invalid'),
|
|
35
|
+
'health-check should report base-url-invalid'
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
const healthRemote = await api('config-health-check', { remote: true });
|
|
39
|
+
assert(healthRemote.ok === false, 'health-check(remote) should fail');
|
|
40
|
+
assert(
|
|
41
|
+
Array.isArray(healthRemote.issues) &&
|
|
42
|
+
healthRemote.issues.some(issue => issue.code === 'remote-skip-base-url'),
|
|
43
|
+
'health-check(remote) should report remote-skip-base-url'
|
|
44
|
+
);
|
|
45
|
+
} finally {
|
|
46
|
+
require('fs').writeFileSync(configPath, originalConfig, 'utf-8');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const speedInvalid = await api('speed-test', { url: 'not-a-url' });
|
|
50
|
+
assert(speedInvalid.ok === false || speedInvalid.error, 'speed-test invalid url should fail');
|
|
51
|
+
|
|
52
|
+
const speedAuthFail = await api('speed-test', { url: authFailUrl });
|
|
53
|
+
assert(
|
|
54
|
+
speedAuthFail.status === 401 || (speedAuthFail.error && /401/.test(speedAuthFail.error)),
|
|
55
|
+
'speed-test auth fail should expose 401'
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
const speedUnreachable = await api('speed-test', { url: 'http://127.0.0.1:1' });
|
|
59
|
+
assert(speedUnreachable.ok === false || speedUnreachable.error, 'speed-test unreachable should fail');
|
|
60
|
+
|
|
61
|
+
assert(normalizeWireApi('chat/completions') === 'chat_completions', 'normalizeWireApi should replace "/" with "_"');
|
|
62
|
+
const probeSpec = buildModelProbeSpec({ wire_api: 'chat/completions' }, 'e2e-chat', mockProviderUrl);
|
|
63
|
+
assert(probeSpec && probeSpec.url.endsWith('/chat/completions'), 'buildModelProbeSpec should use chat/completions endpoint for slash wire_api');
|
|
64
|
+
|
|
65
|
+
const permDir = require('fs').mkdtempSync(path.join(tmpHome, 'perm-'));
|
|
66
|
+
const existingPath = path.join(permDir, 'secret.json');
|
|
67
|
+
require('fs').writeFileSync(existingPath, JSON.stringify({ a: 1 }), { mode: 0o640 });
|
|
68
|
+
const origMode = fileMode(existingPath);
|
|
69
|
+
writeJsonAtomic(existingPath, { a: 2 });
|
|
70
|
+
assert(fileMode(existingPath) === origMode, 'writeJsonAtomic should preserve mode of existing file');
|
|
71
|
+
|
|
72
|
+
const newPath = path.join(permDir, 'new.json');
|
|
73
|
+
writeJsonAtomic(newPath, { b: 1 });
|
|
74
|
+
const expectedNewMode = process.platform === 'win32' ? 0o666 : 0o600;
|
|
75
|
+
assert(
|
|
76
|
+
fileMode(newPath) === expectedNewMode,
|
|
77
|
+
`writeJsonAtomic should default to ${expectedNewMode.toString(8)} for new file (got ${fileMode(newPath).toString(8)})`
|
|
78
|
+
);
|
|
79
|
+
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
const { assert } = require('./helpers');
|
|
2
|
+
|
|
3
|
+
module.exports = async function testOpenclaw(ctx) {
|
|
4
|
+
const { api } = ctx;
|
|
5
|
+
|
|
6
|
+
const openclawReadEmpty = await api('get-openclaw-config');
|
|
7
|
+
assert(openclawReadEmpty.exists === false, 'openclaw config should not exist initially');
|
|
8
|
+
|
|
9
|
+
const openclawInvalid = await api('apply-openclaw-config', { content: '', lineEnding: '\n' });
|
|
10
|
+
assert(openclawInvalid.success === false, 'apply-openclaw-config should reject empty content');
|
|
11
|
+
|
|
12
|
+
const openclawContent = [
|
|
13
|
+
'{',
|
|
14
|
+
' "agent": { "model": "gpt-4.1" },',
|
|
15
|
+
' "agents": { "defaults": { "workspace": "~/.openclaw/workspace" } }',
|
|
16
|
+
'}'
|
|
17
|
+
].join('\n');
|
|
18
|
+
const openclawApply = await api('apply-openclaw-config', { content: openclawContent, lineEnding: '\n' });
|
|
19
|
+
assert(openclawApply.success === true, `apply-openclaw-config failed${openclawApply && openclawApply.error ? `: ${openclawApply.error}` : ''}`);
|
|
20
|
+
const openclawReadAfter = await api('get-openclaw-config');
|
|
21
|
+
assert(openclawReadAfter.exists === true, 'openclaw config should exist after apply');
|
|
22
|
+
|
|
23
|
+
const openclawAgentsBefore = await api('get-openclaw-agents-file');
|
|
24
|
+
assert(openclawAgentsBefore.path, 'get-openclaw-agents-file missing path');
|
|
25
|
+
const openclawAgentsApply = await api('apply-openclaw-agents-file', { content: 'openclaw-agents', lineEnding: '\n' });
|
|
26
|
+
assert(openclawAgentsApply.success === true, 'apply-openclaw-agents-file failed');
|
|
27
|
+
const openclawAgentsAfter = await api('get-openclaw-agents-file');
|
|
28
|
+
assert(openclawAgentsAfter.exists === true, 'openclaw agents should exist after apply');
|
|
29
|
+
assert(openclawAgentsAfter.content.includes('openclaw-agents'), 'openclaw agents content mismatch');
|
|
30
|
+
|
|
31
|
+
const openclawWorkspaceInvalid = await api('apply-openclaw-workspace-file', {
|
|
32
|
+
fileName: 'bad.txt',
|
|
33
|
+
content: 'x',
|
|
34
|
+
lineEnding: '\n'
|
|
35
|
+
});
|
|
36
|
+
assert(openclawWorkspaceInvalid.error, 'apply-openclaw-workspace-file should reject invalid name');
|
|
37
|
+
|
|
38
|
+
const openclawWorkspaceApply = await api('apply-openclaw-workspace-file', {
|
|
39
|
+
fileName: 'SOUL.md',
|
|
40
|
+
content: 'workspace-content',
|
|
41
|
+
lineEnding: '\n'
|
|
42
|
+
});
|
|
43
|
+
assert(openclawWorkspaceApply.success === true, 'apply-openclaw-workspace-file failed');
|
|
44
|
+
const openclawWorkspaceRead = await api('get-openclaw-workspace-file', { fileName: 'SOUL.md' });
|
|
45
|
+
assert(openclawWorkspaceRead.exists === true, 'get-openclaw-workspace-file missing after apply');
|
|
46
|
+
assert(openclawWorkspaceRead.content.includes('workspace-content'), 'openclaw workspace content mismatch');
|
|
47
|
+
};
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
const http = require('http');
|
|
2
|
+
const { assert } = require('./helpers');
|
|
3
|
+
|
|
4
|
+
async function fetchHtml(port) {
|
|
5
|
+
return new Promise((resolve, reject) => {
|
|
6
|
+
const req = http.get({
|
|
7
|
+
hostname: '127.0.0.1',
|
|
8
|
+
port,
|
|
9
|
+
path: '/',
|
|
10
|
+
timeout: 2000
|
|
11
|
+
}, (res) => {
|
|
12
|
+
let body = '';
|
|
13
|
+
res.setEncoding('utf-8');
|
|
14
|
+
res.on('data', chunk => body += chunk);
|
|
15
|
+
res.on('end', () => resolve(body));
|
|
16
|
+
});
|
|
17
|
+
req.on('error', reject);
|
|
18
|
+
req.on('timeout', () => {
|
|
19
|
+
req.destroy(new Error('timeout'));
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
module.exports = async function testSessionSearch(ctx) {
|
|
25
|
+
const { api, sessionId, claudeSessionId, daudeSessionId } = ctx;
|
|
26
|
+
|
|
27
|
+
const claudeSearch = await api('list-sessions', { source: 'claude', query: 'claudecode', limit: 20, forceRefresh: true });
|
|
28
|
+
assert(Array.isArray(claudeSearch.sessions), 'claudecode query missing sessions');
|
|
29
|
+
const claudeHit = claudeSearch.sessions.find(item => item.sessionId === claudeSessionId);
|
|
30
|
+
assert(claudeHit, 'claudecode query missing Claude session');
|
|
31
|
+
assert(claudeHit.provider === 'claude', 'Claude session provider missing');
|
|
32
|
+
assert(claudeHit.capabilities && claudeHit.capabilities.code === true, 'Claude session code capability missing');
|
|
33
|
+
assert(Array.isArray(claudeHit.keywords) && claudeHit.keywords.includes('claude_code'), 'Claude session keyword missing');
|
|
34
|
+
|
|
35
|
+
const variantSearch = await api('list-sessions', { source: 'claude', query: 'claude-code', limit: 20, forceRefresh: true });
|
|
36
|
+
assert(variantSearch.sessions.some(item => item.sessionId === claudeSessionId), 'claude-code query missing Claude session');
|
|
37
|
+
|
|
38
|
+
const combined = await api('list-sessions', { source: 'claude', query: 'claude code hello', limit: 20, forceRefresh: true });
|
|
39
|
+
const combinedIds = combined.sessions.map(item => item.sessionId);
|
|
40
|
+
assert(combinedIds.includes(claudeSessionId), 'combined query missing Claude session');
|
|
41
|
+
|
|
42
|
+
const baseline = await api('list-sessions', { source: 'codex', query: 'hello', limit: 20, forceRefresh: true });
|
|
43
|
+
assert(baseline.sessions.some(item => item.sessionId === sessionId), 'baseline query missing codex session');
|
|
44
|
+
|
|
45
|
+
const daudeSearch = await api('list-sessions', {
|
|
46
|
+
source: 'codex',
|
|
47
|
+
query: 'daude code',
|
|
48
|
+
limit: 20,
|
|
49
|
+
forceRefresh: true
|
|
50
|
+
});
|
|
51
|
+
assert(daudeSearch.sessions.some(item => item.sessionId === daudeSessionId), 'daude code query missing session');
|
|
52
|
+
|
|
53
|
+
const daudeHyphen = await api('list-sessions', {
|
|
54
|
+
source: 'codex',
|
|
55
|
+
query: 'daude-code',
|
|
56
|
+
limit: 20,
|
|
57
|
+
forceRefresh: true
|
|
58
|
+
});
|
|
59
|
+
assert(daudeHyphen.sessions.some(item => item.sessionId === daudeSessionId), 'daude-code query missing session');
|
|
60
|
+
|
|
61
|
+
const daudeConcat = await api('list-sessions', {
|
|
62
|
+
source: 'codex',
|
|
63
|
+
query: 'daudecode',
|
|
64
|
+
limit: 20,
|
|
65
|
+
forceRefresh: true
|
|
66
|
+
});
|
|
67
|
+
assert(daudeConcat.sessions.some(item => item.sessionId === daudeSessionId), 'daudecode query missing session');
|
|
68
|
+
|
|
69
|
+
const highlighted = await api('list-sessions', {
|
|
70
|
+
source: 'claude',
|
|
71
|
+
query: 'claude code hello',
|
|
72
|
+
queryScope: 'all',
|
|
73
|
+
contentScanBytes: 8 * 1024,
|
|
74
|
+
limit: 5,
|
|
75
|
+
forceRefresh: true
|
|
76
|
+
});
|
|
77
|
+
const highlightedHit = highlighted.sessions.find(item => item.sessionId === claudeSessionId);
|
|
78
|
+
assert(highlightedHit, 'highlight query missing Claude session');
|
|
79
|
+
assert(highlightedHit.match && highlightedHit.match.hit === true, 'highlight match missing for Claude session');
|
|
80
|
+
assert(Array.isArray(highlightedHit.match.snippets) && highlightedHit.match.snippets.some(
|
|
81
|
+
snippet => typeof snippet === 'string' && snippet.toLowerCase().includes('hello from claude code session')
|
|
82
|
+
), 'highlight snippets missing Claude code text');
|
|
83
|
+
|
|
84
|
+
const numeric = await api('list-sessions', {
|
|
85
|
+
source: 'codex',
|
|
86
|
+
query: '222',
|
|
87
|
+
queryScope: 'all',
|
|
88
|
+
contentScanBytes: 8 * 1024,
|
|
89
|
+
limit: 10,
|
|
90
|
+
forceRefresh: true
|
|
91
|
+
});
|
|
92
|
+
const numericHit = numeric.sessions.find(item => item.sessionId === daudeSessionId);
|
|
93
|
+
assert(numericHit, '222 query missing daude session');
|
|
94
|
+
assert(numericHit.match && numericHit.match.hit === true, '222 match missing daude session hit');
|
|
95
|
+
assert(Array.isArray(numericHit.match.snippets) && numericHit.match.snippets.some(
|
|
96
|
+
snippet => typeof snippet === 'string' && snippet.includes('222')
|
|
97
|
+
), '222 snippets missing numeric token');
|
|
98
|
+
|
|
99
|
+
const paged = await api('list-sessions', {
|
|
100
|
+
source: 'claude',
|
|
101
|
+
query: 'claude code hello',
|
|
102
|
+
queryScope: 'all',
|
|
103
|
+
limit: 1,
|
|
104
|
+
forceRefresh: true
|
|
105
|
+
});
|
|
106
|
+
assert(Array.isArray(paged.sessions) && paged.sessions.length === 1, 'paged search should return first page only');
|
|
107
|
+
assert(paged.sessions[0].sessionId === claudeSessionId, 'paged search first item should be Claude session');
|
|
108
|
+
|
|
109
|
+
const html = await fetchHtml(ctx.port);
|
|
110
|
+
assert(html && html.includes('.session-item-snippet'), 'session snippet style missing');
|
|
111
|
+
const lowerHtml = (html || '').toLowerCase();
|
|
112
|
+
assert(lowerHtml.includes('white-space: nowrap'), 'snippet nowrap missing');
|
|
113
|
+
assert(lowerHtml.includes('text-overflow: ellipsis'), 'snippet ellipsis missing');
|
|
114
|
+
};
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
const path = require('path');
|
|
2
|
+
const { assert } = require('./helpers');
|
|
3
|
+
|
|
4
|
+
module.exports = async function testSessions(ctx) {
|
|
5
|
+
const { api, sessionId, tmpHome, claudeSessionId } = ctx;
|
|
6
|
+
|
|
7
|
+
const apiSessions = await api('list-sessions', { source: 'codex', limit: 50, forceRefresh: true });
|
|
8
|
+
assert(Array.isArray(apiSessions.sessions), 'api sessions missing');
|
|
9
|
+
assert(apiSessions.sessions.some(item => item.sessionId === sessionId), 'api sessions missing codex entry');
|
|
10
|
+
|
|
11
|
+
const apiSessionsClaude = await api('list-sessions', { source: 'claude', limit: 50, forceRefresh: true });
|
|
12
|
+
assert(Array.isArray(apiSessionsClaude.sessions), 'api sessions(claude) missing');
|
|
13
|
+
assert(apiSessionsClaude.sessions.some(item => item.sessionId === claudeSessionId), 'api sessions(claude) missing claude entry');
|
|
14
|
+
|
|
15
|
+
const claudeCodeQuery = await api('list-sessions', { source: 'claude', query: 'claude code', limit: 50, forceRefresh: true });
|
|
16
|
+
assert(Array.isArray(claudeCodeQuery.sessions), 'claude code query missing sessions');
|
|
17
|
+
const claudeCodeHit = claudeCodeQuery.sessions.find(item => item.sessionId === claudeSessionId);
|
|
18
|
+
assert(claudeCodeHit, 'claude code query missing Claude session');
|
|
19
|
+
assert(claudeCodeHit.provider === 'claude', 'claude code query provider mismatch');
|
|
20
|
+
assert(claudeCodeHit.capabilities && claudeCodeHit.capabilities.code === true, 'claude code query missing code capability');
|
|
21
|
+
assert(Array.isArray(claudeCodeHit.keywords) && claudeCodeHit.keywords.includes('claude_code'), 'claude code query missing keyword');
|
|
22
|
+
|
|
23
|
+
const sessionDetail = await api('session-detail', { source: 'codex', sessionId });
|
|
24
|
+
assert(Array.isArray(sessionDetail.messages), 'session-detail missing messages');
|
|
25
|
+
|
|
26
|
+
const sessionPlain = await api('session-plain', { source: 'codex', sessionId });
|
|
27
|
+
assert(sessionPlain.text && sessionPlain.text.includes('world'), 'session-plain missing content');
|
|
28
|
+
|
|
29
|
+
const sessionPlainMissing = await api('session-plain', { source: 'codex', sessionId: 'missing-session' });
|
|
30
|
+
assert(sessionPlainMissing.error, 'session-plain should fail for missing session');
|
|
31
|
+
|
|
32
|
+
const exportSession = await api('export-session', { source: 'codex', sessionId, maxMessages: 1 });
|
|
33
|
+
assert(exportSession.content, 'export-session missing content');
|
|
34
|
+
assert(exportSession.truncated === true, 'export-session should be truncated with maxMessages');
|
|
35
|
+
|
|
36
|
+
const cloneResult = await api('clone-session', { source: 'codex', sessionId });
|
|
37
|
+
assert(cloneResult.success === true, 'clone-session failed');
|
|
38
|
+
assert(cloneResult.sessionId && cloneResult.sessionId !== sessionId, 'clone-session id invalid');
|
|
39
|
+
assert(cloneResult.filePath && cloneResult.filePath.endsWith('.jsonl'), 'clone-session file path invalid');
|
|
40
|
+
assert(cloneResult.filePath && require('fs').existsSync(cloneResult.filePath), 'clone-session file missing');
|
|
41
|
+
const cloneSessionId = cloneResult.sessionId;
|
|
42
|
+
|
|
43
|
+
const cloneInvalid = await api('clone-session', { source: 'claude', sessionId });
|
|
44
|
+
assert(cloneInvalid.error, 'clone-session should reject non-codex source');
|
|
45
|
+
|
|
46
|
+
const apiSessionsAfterClone = await api('list-sessions', { source: 'codex', limit: 50, forceRefresh: true });
|
|
47
|
+
assert(Array.isArray(apiSessionsAfterClone.sessions), 'api sessions after clone missing');
|
|
48
|
+
assert(
|
|
49
|
+
apiSessionsAfterClone.sessions[0]
|
|
50
|
+
&& apiSessionsAfterClone.sessions[0].sessionId === cloneSessionId,
|
|
51
|
+
'clone session not latest'
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
const deleteResult = await api('delete-session', { source: 'codex', sessionId });
|
|
55
|
+
assert(deleteResult.success === true, 'delete-session failed');
|
|
56
|
+
|
|
57
|
+
const deleteMissing = await api('delete-session', { source: 'codex', sessionId });
|
|
58
|
+
assert(deleteMissing.error, 'delete-session should fail for missing session');
|
|
59
|
+
|
|
60
|
+
const detailMissing = await api('session-detail', { source: 'codex', sessionId });
|
|
61
|
+
assert(detailMissing.error, 'session-detail should fail after delete');
|
|
62
|
+
|
|
63
|
+
const apiSessionsAfterDelete = await api('list-sessions', { source: 'codex', limit: 50, forceRefresh: true });
|
|
64
|
+
assert(!apiSessionsAfterDelete.sessions.some(item => item.sessionId === sessionId), 'deleted session still listed');
|
|
65
|
+
assert(apiSessionsAfterDelete.sessions.some(item => item.sessionId === cloneSessionId), 'clone session missing after delete');
|
|
66
|
+
|
|
67
|
+
Object.assign(ctx, { cloneSessionId });
|
|
68
|
+
};
|
|
69
|
+
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
const path = require('path');
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const {
|
|
4
|
+
assert,
|
|
5
|
+
runSync,
|
|
6
|
+
runWithInput
|
|
7
|
+
} = require('./helpers');
|
|
8
|
+
|
|
9
|
+
module.exports = async function testSetup(ctx) {
|
|
10
|
+
const { env, node, cliPath, mockProviderUrl, noModelsUrl, htmlModelsUrl, authFailUrl, tmpHome } = ctx;
|
|
11
|
+
|
|
12
|
+
const setupInput = [
|
|
13
|
+
'2',
|
|
14
|
+
'e2e',
|
|
15
|
+
mockProviderUrl,
|
|
16
|
+
'sk-test',
|
|
17
|
+
'e2e-model',
|
|
18
|
+
''
|
|
19
|
+
].join('\n');
|
|
20
|
+
|
|
21
|
+
const setupResult = await runWithInput(node, [cliPath, 'setup'], setupInput, { env });
|
|
22
|
+
if (setupResult.status !== 0) {
|
|
23
|
+
const errorText = setupResult.stderr || setupResult.stdout || '';
|
|
24
|
+
if (errorText.includes('EPERM')) {
|
|
25
|
+
ctx.skipE2E = 'child_process spawn blocked (EPERM) during setup';
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
assert(setupResult.status === 0, `setup failed: ${errorText}`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const configPath = path.join(tmpHome, '.codex', 'config.toml');
|
|
32
|
+
assert(fs.existsSync(configPath), 'config.toml missing');
|
|
33
|
+
const configContent = fs.readFileSync(configPath, 'utf-8');
|
|
34
|
+
assert(/model_provider\s*=\s*"e2e"/.test(configContent), 'model_provider not set');
|
|
35
|
+
assert(/model\s*=\s*"e2e-model"/.test(configContent), 'model not set');
|
|
36
|
+
assert(/\[model_providers\.e2e\]/.test(configContent), 'provider block missing');
|
|
37
|
+
assert(configContent.includes(`base_url = "${mockProviderUrl}"`), 'base_url missing or mismatched');
|
|
38
|
+
|
|
39
|
+
const authPath = path.join(tmpHome, '.codex', 'auth.json');
|
|
40
|
+
const auth = JSON.parse(fs.readFileSync(authPath, 'utf-8'));
|
|
41
|
+
assert(auth.OPENAI_API_KEY === 'sk-test', 'auth api_key mismatch');
|
|
42
|
+
|
|
43
|
+
const modelsPath = path.join(tmpHome, '.codex', 'models.json');
|
|
44
|
+
const models = JSON.parse(fs.readFileSync(modelsPath, 'utf-8'));
|
|
45
|
+
assert(models.includes('e2e-model'), 'custom model not added');
|
|
46
|
+
|
|
47
|
+
const statusResult = runSync(node, [cliPath, 'status'], { env });
|
|
48
|
+
assert(statusResult.status === 0, 'status failed');
|
|
49
|
+
assert(statusResult.stdout.includes('e2e'), 'status provider not shown');
|
|
50
|
+
assert(statusResult.stdout.includes('e2e-model'), 'status model not shown');
|
|
51
|
+
|
|
52
|
+
const listResult = runSync(node, [cliPath, 'list'], { env });
|
|
53
|
+
assert(listResult.status === 0, 'list failed');
|
|
54
|
+
assert(listResult.stdout.includes('e2e'), 'list missing provider');
|
|
55
|
+
|
|
56
|
+
const claudeModel = 'claude-e2e';
|
|
57
|
+
const claudeResult = runSync(node, [cliPath, 'claude', mockProviderUrl, 'sk-claude', claudeModel], { env });
|
|
58
|
+
assert(claudeResult.status === 0, `claude command failed: ${claudeResult.stderr || claudeResult.stdout}`);
|
|
59
|
+
const claudeSettingsPath = path.join(tmpHome, '.claude', 'settings.json');
|
|
60
|
+
assert(fs.existsSync(claudeSettingsPath), 'claude settings missing');
|
|
61
|
+
const claudeSettings = JSON.parse(fs.readFileSync(claudeSettingsPath, 'utf-8'));
|
|
62
|
+
assert(claudeSettings.env && claudeSettings.env.ANTHROPIC_API_KEY === 'sk-claude', 'claude API key mismatch');
|
|
63
|
+
assert(claudeSettings.env.ANTHROPIC_BASE_URL === mockProviderUrl, 'claude base url mismatch');
|
|
64
|
+
assert(claudeSettings.env.ANTHROPIC_MODEL === claudeModel, 'claude model mismatch');
|
|
65
|
+
|
|
66
|
+
const sessionsDir = path.join(tmpHome, '.codex', 'sessions');
|
|
67
|
+
fs.mkdirSync(sessionsDir, { recursive: true });
|
|
68
|
+
const sessionId = 'e2e-session';
|
|
69
|
+
const sessionPath = path.join(sessionsDir, `${sessionId}.jsonl`);
|
|
70
|
+
const sessionRecords = [
|
|
71
|
+
{
|
|
72
|
+
type: 'session_meta',
|
|
73
|
+
payload: { id: sessionId, cwd: '/tmp/e2e' },
|
|
74
|
+
timestamp: '2025-01-01T00:00:00.000Z'
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
type: 'response_item',
|
|
78
|
+
payload: { type: 'message', role: 'user', content: 'hello' },
|
|
79
|
+
timestamp: '2025-01-01T00:00:01.000Z'
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
type: 'response_item',
|
|
83
|
+
payload: { type: 'message', role: 'assistant', content: 'world' },
|
|
84
|
+
timestamp: '2025-01-01T00:00:02.000Z'
|
|
85
|
+
}
|
|
86
|
+
];
|
|
87
|
+
fs.writeFileSync(sessionPath, sessionRecords.map(record => JSON.stringify(record)).join('\n') + '\n', 'utf-8');
|
|
88
|
+
|
|
89
|
+
const daudeSessionId = 'daude-e2e-session';
|
|
90
|
+
const daudeSessionPath = path.join(sessionsDir, `${daudeSessionId}.jsonl`);
|
|
91
|
+
const daudeRecords = [
|
|
92
|
+
{
|
|
93
|
+
type: 'session_meta',
|
|
94
|
+
payload: { id: daudeSessionId, cwd: '/tmp/daude' },
|
|
95
|
+
timestamp: '2025-02-02T00:00:00.000Z'
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
type: 'response_item',
|
|
99
|
+
payload: { type: 'message', role: 'user', content: 'daude code quick start 222' },
|
|
100
|
+
timestamp: '2025-02-02T00:00:01.000Z'
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
type: 'response_item',
|
|
104
|
+
payload: { type: 'message', role: 'assistant', content: 'sharing daude-code bootstrap' },
|
|
105
|
+
timestamp: '2025-02-02T00:00:02.000Z'
|
|
106
|
+
}
|
|
107
|
+
];
|
|
108
|
+
fs.writeFileSync(daudeSessionPath, daudeRecords.map(record => JSON.stringify(record)).join('\n') + '\n', 'utf-8');
|
|
109
|
+
|
|
110
|
+
const claudeProjectsDir = path.join(tmpHome, '.claude', 'projects');
|
|
111
|
+
const claudeProjectDir = path.join(claudeProjectsDir, 'e2e-project');
|
|
112
|
+
fs.mkdirSync(claudeProjectDir, { recursive: true });
|
|
113
|
+
const claudeSessionId = 'claude-e2e-session';
|
|
114
|
+
const claudeSessionPath = path.join(claudeProjectDir, `${claudeSessionId}.jsonl`);
|
|
115
|
+
const claudeRecords = [
|
|
116
|
+
{
|
|
117
|
+
type: 'user',
|
|
118
|
+
message: { content: 'hello from claude code session' },
|
|
119
|
+
timestamp: '2025-02-01T00:00:00.000Z'
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
type: 'assistant',
|
|
123
|
+
message: { content: 'initialized project' },
|
|
124
|
+
timestamp: '2025-02-01T00:00:01.000Z'
|
|
125
|
+
}
|
|
126
|
+
];
|
|
127
|
+
fs.writeFileSync(claudeSessionPath, claudeRecords.map(record => JSON.stringify(record)).join('\n') + '\n', 'utf-8');
|
|
128
|
+
const claudeIndexPath = path.join(claudeProjectDir, 'sessions-index.json');
|
|
129
|
+
const claudeIndex = {
|
|
130
|
+
entries: [
|
|
131
|
+
{
|
|
132
|
+
sessionId: claudeSessionId,
|
|
133
|
+
projectPath: claudeProjectDir,
|
|
134
|
+
fullPath: claudeSessionPath,
|
|
135
|
+
created: '2025-02-01T00:00:00.000Z',
|
|
136
|
+
modified: '2025-02-01T00:00:01.000Z',
|
|
137
|
+
summary: 'Claude Code sample session',
|
|
138
|
+
provider: 'claude',
|
|
139
|
+
capabilities: { code: true },
|
|
140
|
+
keywords: ['claude_code', 'sample'],
|
|
141
|
+
messageCount: 2
|
|
142
|
+
}
|
|
143
|
+
]
|
|
144
|
+
};
|
|
145
|
+
fs.writeFileSync(claudeIndexPath, JSON.stringify(claudeIndex, null, 2), 'utf-8');
|
|
146
|
+
|
|
147
|
+
Object.assign(ctx, {
|
|
148
|
+
claudeModel,
|
|
149
|
+
sessionId,
|
|
150
|
+
sessionPath,
|
|
151
|
+
daudeSessionId,
|
|
152
|
+
daudeSessionPath,
|
|
153
|
+
claudeSessionId,
|
|
154
|
+
claudeSessionPath,
|
|
155
|
+
noModelsUrl,
|
|
156
|
+
htmlModelsUrl,
|
|
157
|
+
authFailUrl
|
|
158
|
+
});
|
|
159
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import { fileURLToPath, pathToFileURL } from 'url';
|
|
3
|
+
|
|
4
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
5
|
+
const __dirname = path.dirname(__filename);
|
|
6
|
+
|
|
7
|
+
const tests = [];
|
|
8
|
+
globalThis.test = (name, fn) => tests.push({ name, fn });
|
|
9
|
+
|
|
10
|
+
await import(pathToFileURL(path.join(__dirname, 'web-ui-logic.test.mjs')));
|
|
11
|
+
|
|
12
|
+
let failures = 0;
|
|
13
|
+
for (const { name, fn } of tests) {
|
|
14
|
+
try {
|
|
15
|
+
await fn();
|
|
16
|
+
console.log(`\u2713 ${name}`);
|
|
17
|
+
} catch (err) {
|
|
18
|
+
failures += 1;
|
|
19
|
+
console.error(`\u2717 ${name}`);
|
|
20
|
+
console.error(err);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (failures) {
|
|
25
|
+
console.error(`Failed ${failures} test(s).`);
|
|
26
|
+
process.exit(1);
|
|
27
|
+
} else {
|
|
28
|
+
console.log(`All ${tests.length} tests passed.`);
|
|
29
|
+
}
|