token-studio 4.8.0
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/.nvmrc +1 -0
- package/CHANGELOG.md +89 -0
- package/Dockerfile +17 -0
- package/LICENSE +22 -0
- package/NOTICE.md +21 -0
- package/PRIVACY.md +68 -0
- package/README.en.md +220 -0
- package/README.md +220 -0
- package/config/collectors.json +54 -0
- package/data/.gitkeep +1 -0
- package/docker-compose.yml +17 -0
- package/docs/assets/.gitkeep +1 -0
- package/docs/assets/token-studio-v44-dashboard.png +0 -0
- package/docs/assets/token-studio-v44-live.png +0 -0
- package/docs/assets/token-studio-v44-review-mobile.png +0 -0
- package/docs/assets/token-studio-v44-review.png +0 -0
- package/docs/assets/token-studio-v45-dashboard.png +0 -0
- package/docs/assets/token-studio-v45-live.png +0 -0
- package/docs/assets/token-studio-v45-review-mobile.png +0 -0
- package/docs/assets/token-studio-v45-review.png +0 -0
- package/docs/blog-case-study.md +34 -0
- package/docs/collector-support-matrix.md +65 -0
- package/docs/competitive-notes.md +87 -0
- package/docs/demo-data/README.md +12 -0
- package/docs/demo-data/token-studio-v2-demo.json +146 -0
- package/docs/demo-flow.md +39 -0
- package/docs/first-run.md +95 -0
- package/docs/local-collectors.md +49 -0
- package/docs/public-launch-checklist.md +45 -0
- package/docs/resume-bullets.md +7 -0
- package/docs/statusline.md +52 -0
- package/index.html +16 -0
- package/package.json +36 -0
- package/render.yaml +17 -0
- package/src/auto-attribution.mjs +396 -0
- package/src/ccusage-bridge.mjs +74 -0
- package/src/ccusage-import.mjs +415 -0
- package/src/cli.mjs +643 -0
- package/src/client/dashboard/App.jsx +1734 -0
- package/src/client/dashboard/annotation-presets.js +138 -0
- package/src/client/dashboard/attribution.js +328 -0
- package/src/client/dashboard/components-charts.jsx +622 -0
- package/src/client/dashboard/components-tables.jsx +1531 -0
- package/src/client/dashboard/components-top.jsx +307 -0
- package/src/client/dashboard/import-budget.js +41 -0
- package/src/client/dashboard/model-usage.js +108 -0
- package/src/client/dashboard/onboarding.js +80 -0
- package/src/client/dashboard/styles.css +2606 -0
- package/src/client/live/LiveApp.jsx +226 -0
- package/src/client/live/styles.css +446 -0
- package/src/client/main.jsx +20 -0
- package/src/client/review/ReviewApp.jsx +507 -0
- package/src/client/review/closure-progress.js +165 -0
- package/src/client/review/markdown-report.js +401 -0
- package/src/client/review/model-strategy.js +273 -0
- package/src/client/review/roi-advisor.js +255 -0
- package/src/client/review/roi-evidence.js +78 -0
- package/src/client/review/savings-simulator.js +252 -0
- package/src/client/review/sections-1.jsx +277 -0
- package/src/client/review/sections-2.jsx +927 -0
- package/src/client/review/styles.css +2321 -0
- package/src/client/review/utils.js +345 -0
- package/src/client/shared/utils.js +236 -0
- package/src/closure-check.mjs +537 -0
- package/src/closure-import.mjs +646 -0
- package/src/collect.mjs +247 -0
- package/src/collector-config.mjs +82 -0
- package/src/collector-registry.mjs +333 -0
- package/src/collectors/claude-code.mjs +355 -0
- package/src/collectors/codex.mjs +418 -0
- package/src/collectors/copilot.mjs +19 -0
- package/src/collectors/cursor.mjs +23 -0
- package/src/collectors/gemini.mjs +530 -0
- package/src/collectors/goose.mjs +15 -0
- package/src/collectors/hermes.mjs +206 -0
- package/src/collectors/kimi.mjs +15 -0
- package/src/collectors/openclaw.mjs +400 -0
- package/src/collectors/opencode.mjs +349 -0
- package/src/collectors/qwen.mjs +15 -0
- package/src/collectors/structured-usage.mjs +437 -0
- package/src/collectors/utils.mjs +93 -0
- package/src/db.mjs +1397 -0
- package/src/demo-seed.mjs +39 -0
- package/src/dev.mjs +43 -0
- package/src/live.mjs +428 -0
- package/src/model-policy.mjs +147 -0
- package/src/pricing.mjs +434 -0
- package/src/privacy-check.mjs +126 -0
- package/src/server.mjs +1240 -0
- package/src/source-health.mjs +195 -0
- package/src/statusline.mjs +156 -0
- package/src/terminal-report.mjs +245 -0
- package/src/update-pricing.mjs +8 -0
- package/test/annotation-presets.test.mjs +137 -0
- package/test/api-annotations.test.mjs +202 -0
- package/test/api-auto-attribution.test.mjs +169 -0
- package/test/api-source-health.test.mjs +109 -0
- package/test/api-v2.test.mjs +278 -0
- package/test/api-v43.test.mjs +151 -0
- package/test/api-v44.test.mjs +128 -0
- package/test/attribution-summary.test.mjs +164 -0
- package/test/auto-attribution.test.mjs +116 -0
- package/test/ccusage-bridge.test.mjs +36 -0
- package/test/ccusage-import.test.mjs +93 -0
- package/test/cli-v43.test.mjs +64 -0
- package/test/cli-v45.test.mjs +34 -0
- package/test/cli-v46.test.mjs +129 -0
- package/test/cli-v47.test.mjs +98 -0
- package/test/closure-check.test.mjs +202 -0
- package/test/closure-import.test.mjs +263 -0
- package/test/collector-config.test.mjs +25 -0
- package/test/collector-registry.test.mjs +56 -0
- package/test/csv.test.mjs +19 -0
- package/test/db-annotations.test.mjs +186 -0
- package/test/db-v2.test.mjs +200 -0
- package/test/db-v4.test.mjs +178 -0
- package/test/experimental-collectors.test.mjs +103 -0
- package/test/fixtures/collectors/copilot/usage.jsonl +2 -0
- package/test/fixtures/collectors/cursor/usage.jsonl +2 -0
- package/test/fixtures/collectors/goose/usage.jsonl +2 -0
- package/test/fixtures/collectors/kimi/usage.jsonl +2 -0
- package/test/fixtures/collectors/qwen/usage.jsonl +2 -0
- package/test/import-budget.test.mjs +40 -0
- package/test/live.test.mjs +256 -0
- package/test/markdown-report.test.mjs +193 -0
- package/test/model-policy.test.mjs +34 -0
- package/test/model-strategy.test.mjs +116 -0
- package/test/model-usage.test.mjs +99 -0
- package/test/official-pricing.test.mjs +70 -0
- package/test/onboarding.test.mjs +55 -0
- package/test/privacy-check.test.mjs +33 -0
- package/test/review-closure-progress.test.mjs +99 -0
- package/test/roi-advisor.test.mjs +188 -0
- package/test/roi-evidence.test.mjs +48 -0
- package/test/roi-summary.test.mjs +101 -0
- package/test/savings-simulator.test.mjs +141 -0
- package/test/source-health.test.mjs +62 -0
- package/test/statusline.test.mjs +148 -0
- package/vite.config.js +23 -0
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { existsSync, mkdtempSync, rmSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import test from 'node:test';
|
|
7
|
+
import { openDb, upsertDaily, upsertSession } from '../src/db.mjs';
|
|
8
|
+
|
|
9
|
+
test('v2 APIs cover alias rules, batch annotations, outputs, backup, export and import', async () => {
|
|
10
|
+
const dir = mkdtempSync(join(tmpdir(), 'token-studio-api-v2-'));
|
|
11
|
+
const dbPath = join(dir, 'usage.sqlite');
|
|
12
|
+
const backupDir = join(dir, 'backups');
|
|
13
|
+
const port = 5300 + Math.floor(Math.random() * 1000);
|
|
14
|
+
seedDb(dbPath);
|
|
15
|
+
|
|
16
|
+
const child = spawn(process.execPath, ['src/server.mjs'], {
|
|
17
|
+
cwd: process.cwd(),
|
|
18
|
+
env: {
|
|
19
|
+
...process.env,
|
|
20
|
+
PORT: String(port),
|
|
21
|
+
DB_PATH: dbPath,
|
|
22
|
+
BACKUP_DIR: backupDir,
|
|
23
|
+
SCHEDULED_COLLECT_ENABLED: 'false'
|
|
24
|
+
},
|
|
25
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
26
|
+
windowsHide: true
|
|
27
|
+
});
|
|
28
|
+
let stdout = '';
|
|
29
|
+
let stderr = '';
|
|
30
|
+
child.stdout.setEncoding('utf8');
|
|
31
|
+
child.stderr.setEncoding('utf8');
|
|
32
|
+
child.stdout.on('data', chunk => { stdout += chunk; });
|
|
33
|
+
child.stderr.on('data', chunk => { stderr += chunk; });
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
await waitForApi(port, () => ({ stdout, stderr, exited: child.exitCode != null, exitCode: child.exitCode }));
|
|
37
|
+
assert.match(stdout, /listening on 127\.0\.0\.1/);
|
|
38
|
+
|
|
39
|
+
const rulePayload = {
|
|
40
|
+
pattern: 'D:\\HighROIProjects\\TokenStudio',
|
|
41
|
+
matchType: 'prefix',
|
|
42
|
+
projectAlias: 'Token Studio',
|
|
43
|
+
enabled: true
|
|
44
|
+
};
|
|
45
|
+
const ruleSaved = await postJson(port, '/api/project-alias-rules', rulePayload);
|
|
46
|
+
assert.equal(ruleSaved.rule.projectAlias, 'Token Studio');
|
|
47
|
+
|
|
48
|
+
const rules = await getJson(port, '/api/project-alias-rules');
|
|
49
|
+
assert.equal(rules.rules.length, 1);
|
|
50
|
+
assert.equal(rules.matchTypes.includes('prefix'), true);
|
|
51
|
+
|
|
52
|
+
const withRule = await getJson(port, '/api/data');
|
|
53
|
+
const codex = withRule.sessions.find(session => session.sessionId === 'codex:one');
|
|
54
|
+
assert.equal(codex.projectAlias, 'Token Studio');
|
|
55
|
+
assert.equal(codex.manualProjectAlias, null);
|
|
56
|
+
assert.equal(codex.ruleProjectAlias, 'Token Studio');
|
|
57
|
+
|
|
58
|
+
await postJson(port, '/api/session-annotations', {
|
|
59
|
+
device: 'devbox',
|
|
60
|
+
source: 'Codex CLI',
|
|
61
|
+
sessionId: 'codex:one',
|
|
62
|
+
projectAlias: 'Manual Studio',
|
|
63
|
+
taskType: '功能开发',
|
|
64
|
+
outputStatus: '已完成',
|
|
65
|
+
workPurpose: '方案设计',
|
|
66
|
+
workStage: '实现',
|
|
67
|
+
valueLevel: '高'
|
|
68
|
+
});
|
|
69
|
+
const manual = await getJson(port, '/api/data');
|
|
70
|
+
const manualSession = manual.sessions.find(session => session.sessionId === 'codex:one');
|
|
71
|
+
assert.equal(manualSession.projectAlias, 'Manual Studio');
|
|
72
|
+
assert.equal(manualSession.workPurpose, '方案设计');
|
|
73
|
+
assert.equal(manualSession.workStage, '实现');
|
|
74
|
+
assert.equal(manualSession.valueLevel, '高');
|
|
75
|
+
assert.equal(manual.meta.workPurposes.includes('测试验证'), true);
|
|
76
|
+
assert.equal(manual.meta.workStages.includes('探索'), true);
|
|
77
|
+
assert.equal(manual.meta.valueLevels.includes('关键'), true);
|
|
78
|
+
assert.equal(manual.meta.outputTypes.includes('PR'), true);
|
|
79
|
+
|
|
80
|
+
await postJson(port, '/api/session-outputs', {
|
|
81
|
+
device: 'devbox',
|
|
82
|
+
source: 'Codex CLI',
|
|
83
|
+
sessionId: 'codex:one',
|
|
84
|
+
outputUrl: 'https://github.com/example/repo/pull/42',
|
|
85
|
+
outputLabel: 'PR #42',
|
|
86
|
+
outputType: 'PR'
|
|
87
|
+
});
|
|
88
|
+
const withOutput = await getJson(port, '/api/data');
|
|
89
|
+
const outputSession = withOutput.sessions.find(session => session.sessionId === 'codex:one');
|
|
90
|
+
assert.equal(outputSession.outputLabel, 'PR #42');
|
|
91
|
+
assert.equal(outputSession.outputType, 'PR');
|
|
92
|
+
|
|
93
|
+
await assertRejectsWithStatus(
|
|
94
|
+
fetch(`http://127.0.0.1:${port}/api/session-annotations/batch`, {
|
|
95
|
+
method: 'POST',
|
|
96
|
+
headers: { 'Content-Type': 'text/plain' },
|
|
97
|
+
body: '{}'
|
|
98
|
+
}),
|
|
99
|
+
415
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
await assertRejectsWithStatus(
|
|
103
|
+
fetch(`http://127.0.0.1:${port}/api/session-outputs`, {
|
|
104
|
+
method: 'POST',
|
|
105
|
+
headers: {
|
|
106
|
+
'Content-Type': 'application/json',
|
|
107
|
+
Origin: 'http://evil.example'
|
|
108
|
+
},
|
|
109
|
+
body: JSON.stringify({
|
|
110
|
+
device: 'devbox',
|
|
111
|
+
source: 'Codex CLI',
|
|
112
|
+
sessionId: 'codex:one',
|
|
113
|
+
outputUrl: 'https://example.com'
|
|
114
|
+
})
|
|
115
|
+
}),
|
|
116
|
+
403
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
const batch = await postJson(port, '/api/session-annotations/batch', {
|
|
120
|
+
sessions: [{ device: 'devbox', source: 'Claude Code', sessionId: 'claude:two' }],
|
|
121
|
+
values: { taskType: '问题修复', outputStatus: '已废弃', workPurpose: '测试验证', workStage: '验证', valueLevel: '低', note: '批量处理' }
|
|
122
|
+
});
|
|
123
|
+
assert.equal(batch.updated, 1);
|
|
124
|
+
|
|
125
|
+
const afterBatch = await getJson(port, '/api/data');
|
|
126
|
+
const claude = afterBatch.sessions.find(session => session.sessionId === 'claude:two');
|
|
127
|
+
assert.equal(claude.taskType, '问题修复');
|
|
128
|
+
assert.equal(claude.outputStatus, '已废弃');
|
|
129
|
+
assert.equal(claude.workPurpose, '测试验证');
|
|
130
|
+
assert.equal(claude.workStage, '验证');
|
|
131
|
+
assert.equal(claude.valueLevel, '低');
|
|
132
|
+
|
|
133
|
+
const backup = await postJson(port, '/api/backup', {});
|
|
134
|
+
assert.equal(backup.ok, true);
|
|
135
|
+
assert.equal(existsSync(backup.backup.path), true);
|
|
136
|
+
|
|
137
|
+
const exported = await getJson(port, '/api/export/annotations');
|
|
138
|
+
assert.equal(exported.version, 3);
|
|
139
|
+
assert.equal(exported.sessionAnnotations.length, 2);
|
|
140
|
+
assert.equal(exported.sessionOutputs.length, 1);
|
|
141
|
+
assert.equal(exported.projectAliasRules.length, 1);
|
|
142
|
+
assert.equal(exported.sessionAnnotations.find(row => row.sessionId === 'codex:one').valueLevel, '高');
|
|
143
|
+
assert.equal(exported.sessionOutputs[0].outputType, 'PR');
|
|
144
|
+
|
|
145
|
+
const imported = await postJson(port, '/api/import/annotations', exported);
|
|
146
|
+
assert.deepEqual(imported.imported, {
|
|
147
|
+
sessionAnnotations: 2,
|
|
148
|
+
sessionOutputs: 1,
|
|
149
|
+
projectAliasRules: 1
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
const deletedOutput = await deleteJson(port, '/api/session-outputs', {
|
|
153
|
+
device: 'devbox',
|
|
154
|
+
source: 'Codex CLI',
|
|
155
|
+
sessionId: 'codex:one'
|
|
156
|
+
});
|
|
157
|
+
assert.equal(deletedOutput.deleted, 1);
|
|
158
|
+
|
|
159
|
+
const deletedRule = await deleteJson(port, '/api/project-alias-rules', { id: ruleSaved.rule.id });
|
|
160
|
+
assert.equal(deletedRule.deleted, 1);
|
|
161
|
+
} finally {
|
|
162
|
+
await stopChild(child);
|
|
163
|
+
rmSync(dir, { recursive: true, force: true });
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
function seedDb(dbPath) {
|
|
168
|
+
const db = openDb(dbPath);
|
|
169
|
+
try {
|
|
170
|
+
for (const row of [
|
|
171
|
+
{
|
|
172
|
+
device: 'devbox',
|
|
173
|
+
source: 'Codex CLI',
|
|
174
|
+
usageDate: '2026-06-10',
|
|
175
|
+
model: 'codex-mini',
|
|
176
|
+
inputTokens: 200,
|
|
177
|
+
outputTokens: 100,
|
|
178
|
+
totalTokens: 300,
|
|
179
|
+
costUSD: 0.03
|
|
180
|
+
},
|
|
181
|
+
{
|
|
182
|
+
device: 'devbox',
|
|
183
|
+
source: 'Claude Code',
|
|
184
|
+
usageDate: '2026-06-10',
|
|
185
|
+
model: 'claude-sonnet',
|
|
186
|
+
inputTokens: 400,
|
|
187
|
+
outputTokens: 100,
|
|
188
|
+
totalTokens: 500,
|
|
189
|
+
costUSD: 0.05
|
|
190
|
+
}
|
|
191
|
+
]) {
|
|
192
|
+
upsertDaily(db, row);
|
|
193
|
+
}
|
|
194
|
+
for (const row of [
|
|
195
|
+
{
|
|
196
|
+
device: 'devbox',
|
|
197
|
+
source: 'Codex CLI',
|
|
198
|
+
sessionId: 'codex:one',
|
|
199
|
+
lastActivity: '2026-06-10T01:00:00.000Z',
|
|
200
|
+
projectPath: 'D:\\HighROIProjects\\TokenStudio',
|
|
201
|
+
inputTokens: 200,
|
|
202
|
+
outputTokens: 100,
|
|
203
|
+
totalTokens: 300,
|
|
204
|
+
costUSD: 0.03
|
|
205
|
+
},
|
|
206
|
+
{
|
|
207
|
+
device: 'devbox',
|
|
208
|
+
source: 'Claude Code',
|
|
209
|
+
sessionId: 'claude:two',
|
|
210
|
+
lastActivity: '2026-06-10T02:00:00.000Z',
|
|
211
|
+
projectPath: 'D:\\HighROIProjects\\Other',
|
|
212
|
+
inputTokens: 400,
|
|
213
|
+
outputTokens: 100,
|
|
214
|
+
totalTokens: 500,
|
|
215
|
+
costUSD: 0.05
|
|
216
|
+
}
|
|
217
|
+
]) {
|
|
218
|
+
upsertSession(db, row);
|
|
219
|
+
}
|
|
220
|
+
} finally {
|
|
221
|
+
db.close();
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async function waitForApi(port, diagnostics) {
|
|
226
|
+
const start = Date.now();
|
|
227
|
+
let lastError = null;
|
|
228
|
+
while (Date.now() - start < 5000) {
|
|
229
|
+
try {
|
|
230
|
+
await getJson(port, '/api/data');
|
|
231
|
+
return;
|
|
232
|
+
} catch (error) {
|
|
233
|
+
lastError = error;
|
|
234
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
const details = diagnostics ? diagnostics() : {};
|
|
238
|
+
throw new Error(`server did not start in time: ${lastError?.message || 'no response'}\nstdout=${details.stdout || ''}\nstderr=${details.stderr || ''}\nexit=${details.exited ? details.exitCode : 'running'}`);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async function getJson(port, path) {
|
|
242
|
+
const response = await fetch(`http://127.0.0.1:${port}${path}`);
|
|
243
|
+
if (!response.ok) assert.fail(await response.text());
|
|
244
|
+
return response.json();
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async function postJson(port, path, body) {
|
|
248
|
+
const response = await fetch(`http://127.0.0.1:${port}${path}`, {
|
|
249
|
+
method: 'POST',
|
|
250
|
+
headers: { 'Content-Type': 'application/json' },
|
|
251
|
+
body: JSON.stringify(body)
|
|
252
|
+
});
|
|
253
|
+
if (!response.ok) assert.fail(await response.text());
|
|
254
|
+
return response.json();
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async function deleteJson(port, path, body) {
|
|
258
|
+
const response = await fetch(`http://127.0.0.1:${port}${path}`, {
|
|
259
|
+
method: 'DELETE',
|
|
260
|
+
headers: { 'Content-Type': 'application/json' },
|
|
261
|
+
body: JSON.stringify(body)
|
|
262
|
+
});
|
|
263
|
+
if (!response.ok) assert.fail(await response.text());
|
|
264
|
+
return response.json();
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async function assertRejectsWithStatus(responsePromise, expectedStatus) {
|
|
268
|
+
const response = await responsePromise;
|
|
269
|
+
assert.equal(response.status, expectedStatus, await response.text());
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function stopChild(child) {
|
|
273
|
+
if (child.exitCode != null) return Promise.resolve();
|
|
274
|
+
return new Promise(resolve => {
|
|
275
|
+
child.once('close', resolve);
|
|
276
|
+
child.kill();
|
|
277
|
+
});
|
|
278
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import test from 'node:test';
|
|
7
|
+
import { openDb, upsertTokenEvent } from '../src/db.mjs';
|
|
8
|
+
|
|
9
|
+
test('v4.3 APIs cover budget profiles and advisor actions', async () => {
|
|
10
|
+
const dir = mkdtempSync(join(tmpdir(), 'token-studio-api-v43-'));
|
|
11
|
+
const dbPath = join(dir, 'usage.sqlite');
|
|
12
|
+
const port = 6300 + Math.floor(Math.random() * 1000);
|
|
13
|
+
seedDb(dbPath);
|
|
14
|
+
|
|
15
|
+
const child = spawn(process.execPath, ['src/server.mjs'], {
|
|
16
|
+
cwd: process.cwd(),
|
|
17
|
+
env: {
|
|
18
|
+
...process.env,
|
|
19
|
+
PORT: String(port),
|
|
20
|
+
DB_PATH: dbPath,
|
|
21
|
+
SCHEDULED_COLLECT_ENABLED: 'false'
|
|
22
|
+
},
|
|
23
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
24
|
+
windowsHide: true
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
await waitForApi(port);
|
|
29
|
+
const budget = await postJson(port, '/api/budget-profiles', {
|
|
30
|
+
source: 'Codex CLI',
|
|
31
|
+
label: 'Codex 15m',
|
|
32
|
+
windowMinutes: 15,
|
|
33
|
+
tokenBudget: 1000
|
|
34
|
+
});
|
|
35
|
+
assert.equal(budget.profile.enabled, true);
|
|
36
|
+
|
|
37
|
+
const budgets = await getJson(port, '/api/budget-profiles');
|
|
38
|
+
assert.equal(budgets.profiles.length, 1);
|
|
39
|
+
|
|
40
|
+
const live = await getJson(port, '/api/live');
|
|
41
|
+
assert.equal(live.budgetWindows.length, 1);
|
|
42
|
+
assert.ok(live.warnings.some(item => item.type === 'budget-exceeded'));
|
|
43
|
+
|
|
44
|
+
const action = await postJson(port, '/api/advisor-actions', {
|
|
45
|
+
periodStart: '2026-06-17',
|
|
46
|
+
periodEnd: '2026-06-17',
|
|
47
|
+
category: '节省模拟',
|
|
48
|
+
title: '测试验证换轻量模型',
|
|
49
|
+
action: '下周测试验证默认先用轻量模型',
|
|
50
|
+
evidence: '1 session',
|
|
51
|
+
sourceRule: 'savings:test'
|
|
52
|
+
});
|
|
53
|
+
assert.equal(action.action.status, 'open');
|
|
54
|
+
|
|
55
|
+
const done = await postJson(port, '/api/advisor-actions', {
|
|
56
|
+
...action.action,
|
|
57
|
+
status: 'done'
|
|
58
|
+
});
|
|
59
|
+
assert.equal(done.action.id, action.action.id);
|
|
60
|
+
assert.equal(done.action.status, 'done');
|
|
61
|
+
|
|
62
|
+
const data = await getJson(port, '/api/data');
|
|
63
|
+
assert.equal(data.budgetProfiles.length, 1);
|
|
64
|
+
assert.equal(data.advisorActions[0].status, 'done');
|
|
65
|
+
|
|
66
|
+
await assertRejectsWithStatus(fetch(`http://127.0.0.1:${port}/api/budget-profiles`, {
|
|
67
|
+
method: 'POST',
|
|
68
|
+
headers: { 'Content-Type': 'text/plain' },
|
|
69
|
+
body: '{}'
|
|
70
|
+
}), 415);
|
|
71
|
+
|
|
72
|
+
const deletedAction = await deleteJson(port, `/api/advisor-actions/${action.action.id}`, {});
|
|
73
|
+
assert.equal(deletedAction.deleted, 1);
|
|
74
|
+
const deletedBudget = await deleteJson(port, '/api/budget-profiles', { id: budget.profile.id });
|
|
75
|
+
assert.equal(deletedBudget.deleted, 1);
|
|
76
|
+
} finally {
|
|
77
|
+
await stopChild(child);
|
|
78
|
+
rmSync(dir, { recursive: true, force: true });
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
function seedDb(dbPath) {
|
|
83
|
+
const db = openDb(dbPath);
|
|
84
|
+
try {
|
|
85
|
+
upsertTokenEvent(db, {
|
|
86
|
+
eventId: 'budget-api-warning',
|
|
87
|
+
device: 'devbox',
|
|
88
|
+
source: 'Codex CLI',
|
|
89
|
+
sessionId: 's1',
|
|
90
|
+
timestamp: new Date().toISOString(),
|
|
91
|
+
model: 'gpt-5.3-codex',
|
|
92
|
+
inputTokens: 1200,
|
|
93
|
+
outputTokens: 200
|
|
94
|
+
});
|
|
95
|
+
} finally {
|
|
96
|
+
db.close();
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function waitForApi(port) {
|
|
101
|
+
const start = Date.now();
|
|
102
|
+
while (Date.now() - start < 5000) {
|
|
103
|
+
try {
|
|
104
|
+
const response = await fetch(`http://127.0.0.1:${port}/api/data`);
|
|
105
|
+
if (response.ok) return;
|
|
106
|
+
} catch {
|
|
107
|
+
// Retry while the server starts.
|
|
108
|
+
}
|
|
109
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
110
|
+
}
|
|
111
|
+
throw new Error('server did not start in time');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function getJson(port, path) {
|
|
115
|
+
const response = await fetch(`http://127.0.0.1:${port}${path}`);
|
|
116
|
+
if (!response.ok) assert.fail(await response.text());
|
|
117
|
+
return response.json();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function postJson(port, path, body) {
|
|
121
|
+
const response = await fetch(`http://127.0.0.1:${port}${path}`, {
|
|
122
|
+
method: 'POST',
|
|
123
|
+
headers: { 'Content-Type': 'application/json' },
|
|
124
|
+
body: JSON.stringify(body)
|
|
125
|
+
});
|
|
126
|
+
if (!response.ok) assert.fail(await response.text());
|
|
127
|
+
return response.json();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function deleteJson(port, path, body) {
|
|
131
|
+
const response = await fetch(`http://127.0.0.1:${port}${path}`, {
|
|
132
|
+
method: 'DELETE',
|
|
133
|
+
headers: { 'Content-Type': 'application/json' },
|
|
134
|
+
body: JSON.stringify(body)
|
|
135
|
+
});
|
|
136
|
+
if (!response.ok) assert.fail(await response.text());
|
|
137
|
+
return response.json();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function assertRejectsWithStatus(responsePromise, expectedStatus) {
|
|
141
|
+
const response = await responsePromise;
|
|
142
|
+
assert.equal(response.status, expectedStatus, await response.text());
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function stopChild(child) {
|
|
146
|
+
if (child.exitCode != null) return Promise.resolve();
|
|
147
|
+
return new Promise(resolve => {
|
|
148
|
+
child.once('close', resolve);
|
|
149
|
+
child.kill();
|
|
150
|
+
});
|
|
151
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { existsSync, mkdtempSync, rmSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import test from 'node:test';
|
|
7
|
+
import { openDb } from '../src/db.mjs';
|
|
8
|
+
|
|
9
|
+
test('ccusage import API dry-runs before explicit apply', async () => {
|
|
10
|
+
const dir = mkdtempSync(join(tmpdir(), 'token-studio-api-v44-'));
|
|
11
|
+
const dbPath = join(dir, 'usage.sqlite');
|
|
12
|
+
const port = 7400 + Math.floor(Math.random() * 1000);
|
|
13
|
+
const db = openDb(dbPath);
|
|
14
|
+
db.close();
|
|
15
|
+
|
|
16
|
+
const child = spawn(process.execPath, ['src/server.mjs'], {
|
|
17
|
+
cwd: process.cwd(),
|
|
18
|
+
env: {
|
|
19
|
+
...process.env,
|
|
20
|
+
PORT: String(port),
|
|
21
|
+
DB_PATH: dbPath,
|
|
22
|
+
BACKUP_DIR: join(dir, 'backups'),
|
|
23
|
+
SCHEDULED_COLLECT_ENABLED: 'false'
|
|
24
|
+
},
|
|
25
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
26
|
+
windowsHide: true
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
await waitForApi(port);
|
|
31
|
+
const payload = {
|
|
32
|
+
daily: [{
|
|
33
|
+
date: '2026-06-17',
|
|
34
|
+
source: 'Codex CLI',
|
|
35
|
+
session: 'ccusage-api-s1',
|
|
36
|
+
model: 'vendor-private-unpriced-model',
|
|
37
|
+
inputTokens: 1200,
|
|
38
|
+
outputTokens: 300,
|
|
39
|
+
cacheReadTokens: 100,
|
|
40
|
+
totalTokens: 1600,
|
|
41
|
+
costUSD: 99
|
|
42
|
+
}]
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const dryRun = await postJson(port, '/api/import/ccusage-json', {
|
|
46
|
+
payload,
|
|
47
|
+
apply: false
|
|
48
|
+
});
|
|
49
|
+
assert.equal(dryRun.mode, 'dry-run');
|
|
50
|
+
assert.equal(dryRun.daily, 1);
|
|
51
|
+
assert.equal(dryRun.sessions, 1);
|
|
52
|
+
assert.equal(dryRun.tokenEvents, 1);
|
|
53
|
+
assert.ok(dryRun.warnings.some(item => item.type === 'ignored-imported-cost'));
|
|
54
|
+
|
|
55
|
+
const before = await getJson(port, '/api/data');
|
|
56
|
+
assert.equal(before.daily.length, 0);
|
|
57
|
+
assert.equal(before.sessions.length, 0);
|
|
58
|
+
|
|
59
|
+
const unsafe = await fetch(`http://127.0.0.1:${port}/api/import/ccusage-json`, {
|
|
60
|
+
method: 'POST',
|
|
61
|
+
headers: { 'Content-Type': 'application/json' },
|
|
62
|
+
body: JSON.stringify({
|
|
63
|
+
payload: {
|
|
64
|
+
daily: [{ date: '2026-06-17', model: 'gpt-5.3-codex', inputTokens: 1 }],
|
|
65
|
+
prompt: 'do not import'
|
|
66
|
+
}
|
|
67
|
+
})
|
|
68
|
+
});
|
|
69
|
+
assert.equal(unsafe.status, 400);
|
|
70
|
+
assert.match(await unsafe.text(), /conversation-like field/);
|
|
71
|
+
|
|
72
|
+
const applied = await postJson(port, '/api/import/ccusage-json', {
|
|
73
|
+
payload,
|
|
74
|
+
apply: true
|
|
75
|
+
});
|
|
76
|
+
assert.equal(applied.mode, 'apply');
|
|
77
|
+
assert.equal(applied.applied.daily, 1);
|
|
78
|
+
assert.equal(applied.applied.sessions, 1);
|
|
79
|
+
assert.equal(applied.applied.tokenEvents, 1);
|
|
80
|
+
assert.ok(applied.backup?.path);
|
|
81
|
+
assert.ok(existsSync(applied.backup.path));
|
|
82
|
+
|
|
83
|
+
const after = await getJson(port, '/api/data');
|
|
84
|
+
assert.equal(after.daily.length, 1);
|
|
85
|
+
assert.equal(after.sessions.length, 1);
|
|
86
|
+
} finally {
|
|
87
|
+
await stopChild(child);
|
|
88
|
+
rmSync(dir, { recursive: true, force: true });
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
async function waitForApi(port) {
|
|
93
|
+
const start = Date.now();
|
|
94
|
+
while (Date.now() - start < 5000) {
|
|
95
|
+
try {
|
|
96
|
+
const response = await fetch(`http://127.0.0.1:${port}/api/data`);
|
|
97
|
+
if (response.ok) return;
|
|
98
|
+
} catch {
|
|
99
|
+
// Retry while the server starts.
|
|
100
|
+
}
|
|
101
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
102
|
+
}
|
|
103
|
+
throw new Error('server did not start in time');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function getJson(port, path) {
|
|
107
|
+
const response = await fetch(`http://127.0.0.1:${port}${path}`);
|
|
108
|
+
if (!response.ok) assert.fail(await response.text());
|
|
109
|
+
return response.json();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function postJson(port, path, body) {
|
|
113
|
+
const response = await fetch(`http://127.0.0.1:${port}${path}`, {
|
|
114
|
+
method: 'POST',
|
|
115
|
+
headers: { 'Content-Type': 'application/json' },
|
|
116
|
+
body: JSON.stringify(body)
|
|
117
|
+
});
|
|
118
|
+
if (!response.ok) assert.fail(await response.text());
|
|
119
|
+
return response.json();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function stopChild(child) {
|
|
123
|
+
if (child.exitCode != null) return Promise.resolve();
|
|
124
|
+
return new Promise(resolve => {
|
|
125
|
+
child.once('close', resolve);
|
|
126
|
+
child.kill();
|
|
127
|
+
});
|
|
128
|
+
}
|