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.
Files changed (139) hide show
  1. package/.nvmrc +1 -0
  2. package/CHANGELOG.md +89 -0
  3. package/Dockerfile +17 -0
  4. package/LICENSE +22 -0
  5. package/NOTICE.md +21 -0
  6. package/PRIVACY.md +68 -0
  7. package/README.en.md +220 -0
  8. package/README.md +220 -0
  9. package/config/collectors.json +54 -0
  10. package/data/.gitkeep +1 -0
  11. package/docker-compose.yml +17 -0
  12. package/docs/assets/.gitkeep +1 -0
  13. package/docs/assets/token-studio-v44-dashboard.png +0 -0
  14. package/docs/assets/token-studio-v44-live.png +0 -0
  15. package/docs/assets/token-studio-v44-review-mobile.png +0 -0
  16. package/docs/assets/token-studio-v44-review.png +0 -0
  17. package/docs/assets/token-studio-v45-dashboard.png +0 -0
  18. package/docs/assets/token-studio-v45-live.png +0 -0
  19. package/docs/assets/token-studio-v45-review-mobile.png +0 -0
  20. package/docs/assets/token-studio-v45-review.png +0 -0
  21. package/docs/blog-case-study.md +34 -0
  22. package/docs/collector-support-matrix.md +65 -0
  23. package/docs/competitive-notes.md +87 -0
  24. package/docs/demo-data/README.md +12 -0
  25. package/docs/demo-data/token-studio-v2-demo.json +146 -0
  26. package/docs/demo-flow.md +39 -0
  27. package/docs/first-run.md +95 -0
  28. package/docs/local-collectors.md +49 -0
  29. package/docs/public-launch-checklist.md +45 -0
  30. package/docs/resume-bullets.md +7 -0
  31. package/docs/statusline.md +52 -0
  32. package/index.html +16 -0
  33. package/package.json +36 -0
  34. package/render.yaml +17 -0
  35. package/src/auto-attribution.mjs +396 -0
  36. package/src/ccusage-bridge.mjs +74 -0
  37. package/src/ccusage-import.mjs +415 -0
  38. package/src/cli.mjs +643 -0
  39. package/src/client/dashboard/App.jsx +1734 -0
  40. package/src/client/dashboard/annotation-presets.js +138 -0
  41. package/src/client/dashboard/attribution.js +328 -0
  42. package/src/client/dashboard/components-charts.jsx +622 -0
  43. package/src/client/dashboard/components-tables.jsx +1531 -0
  44. package/src/client/dashboard/components-top.jsx +307 -0
  45. package/src/client/dashboard/import-budget.js +41 -0
  46. package/src/client/dashboard/model-usage.js +108 -0
  47. package/src/client/dashboard/onboarding.js +80 -0
  48. package/src/client/dashboard/styles.css +2606 -0
  49. package/src/client/live/LiveApp.jsx +226 -0
  50. package/src/client/live/styles.css +446 -0
  51. package/src/client/main.jsx +20 -0
  52. package/src/client/review/ReviewApp.jsx +507 -0
  53. package/src/client/review/closure-progress.js +165 -0
  54. package/src/client/review/markdown-report.js +401 -0
  55. package/src/client/review/model-strategy.js +273 -0
  56. package/src/client/review/roi-advisor.js +255 -0
  57. package/src/client/review/roi-evidence.js +78 -0
  58. package/src/client/review/savings-simulator.js +252 -0
  59. package/src/client/review/sections-1.jsx +277 -0
  60. package/src/client/review/sections-2.jsx +927 -0
  61. package/src/client/review/styles.css +2321 -0
  62. package/src/client/review/utils.js +345 -0
  63. package/src/client/shared/utils.js +236 -0
  64. package/src/closure-check.mjs +537 -0
  65. package/src/closure-import.mjs +646 -0
  66. package/src/collect.mjs +247 -0
  67. package/src/collector-config.mjs +82 -0
  68. package/src/collector-registry.mjs +333 -0
  69. package/src/collectors/claude-code.mjs +355 -0
  70. package/src/collectors/codex.mjs +418 -0
  71. package/src/collectors/copilot.mjs +19 -0
  72. package/src/collectors/cursor.mjs +23 -0
  73. package/src/collectors/gemini.mjs +530 -0
  74. package/src/collectors/goose.mjs +15 -0
  75. package/src/collectors/hermes.mjs +206 -0
  76. package/src/collectors/kimi.mjs +15 -0
  77. package/src/collectors/openclaw.mjs +400 -0
  78. package/src/collectors/opencode.mjs +349 -0
  79. package/src/collectors/qwen.mjs +15 -0
  80. package/src/collectors/structured-usage.mjs +437 -0
  81. package/src/collectors/utils.mjs +93 -0
  82. package/src/db.mjs +1397 -0
  83. package/src/demo-seed.mjs +39 -0
  84. package/src/dev.mjs +43 -0
  85. package/src/live.mjs +428 -0
  86. package/src/model-policy.mjs +147 -0
  87. package/src/pricing.mjs +434 -0
  88. package/src/privacy-check.mjs +126 -0
  89. package/src/server.mjs +1240 -0
  90. package/src/source-health.mjs +195 -0
  91. package/src/statusline.mjs +156 -0
  92. package/src/terminal-report.mjs +245 -0
  93. package/src/update-pricing.mjs +8 -0
  94. package/test/annotation-presets.test.mjs +137 -0
  95. package/test/api-annotations.test.mjs +202 -0
  96. package/test/api-auto-attribution.test.mjs +169 -0
  97. package/test/api-source-health.test.mjs +109 -0
  98. package/test/api-v2.test.mjs +278 -0
  99. package/test/api-v43.test.mjs +151 -0
  100. package/test/api-v44.test.mjs +128 -0
  101. package/test/attribution-summary.test.mjs +164 -0
  102. package/test/auto-attribution.test.mjs +116 -0
  103. package/test/ccusage-bridge.test.mjs +36 -0
  104. package/test/ccusage-import.test.mjs +93 -0
  105. package/test/cli-v43.test.mjs +64 -0
  106. package/test/cli-v45.test.mjs +34 -0
  107. package/test/cli-v46.test.mjs +129 -0
  108. package/test/cli-v47.test.mjs +98 -0
  109. package/test/closure-check.test.mjs +202 -0
  110. package/test/closure-import.test.mjs +263 -0
  111. package/test/collector-config.test.mjs +25 -0
  112. package/test/collector-registry.test.mjs +56 -0
  113. package/test/csv.test.mjs +19 -0
  114. package/test/db-annotations.test.mjs +186 -0
  115. package/test/db-v2.test.mjs +200 -0
  116. package/test/db-v4.test.mjs +178 -0
  117. package/test/experimental-collectors.test.mjs +103 -0
  118. package/test/fixtures/collectors/copilot/usage.jsonl +2 -0
  119. package/test/fixtures/collectors/cursor/usage.jsonl +2 -0
  120. package/test/fixtures/collectors/goose/usage.jsonl +2 -0
  121. package/test/fixtures/collectors/kimi/usage.jsonl +2 -0
  122. package/test/fixtures/collectors/qwen/usage.jsonl +2 -0
  123. package/test/import-budget.test.mjs +40 -0
  124. package/test/live.test.mjs +256 -0
  125. package/test/markdown-report.test.mjs +193 -0
  126. package/test/model-policy.test.mjs +34 -0
  127. package/test/model-strategy.test.mjs +116 -0
  128. package/test/model-usage.test.mjs +99 -0
  129. package/test/official-pricing.test.mjs +70 -0
  130. package/test/onboarding.test.mjs +55 -0
  131. package/test/privacy-check.test.mjs +33 -0
  132. package/test/review-closure-progress.test.mjs +99 -0
  133. package/test/roi-advisor.test.mjs +188 -0
  134. package/test/roi-evidence.test.mjs +48 -0
  135. package/test/roi-summary.test.mjs +101 -0
  136. package/test/savings-simulator.test.mjs +141 -0
  137. package/test/source-health.test.mjs +62 -0
  138. package/test/statusline.test.mjs +148 -0
  139. 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
+ }