@opencode-trace/viewer 0.0.4 → 0.0.5
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/README.md +17 -2
- package/dist/cli.js +20 -6
- package/dist/cli.js.map +1 -1
- package/dist/cli.test.d.ts +2 -0
- package/dist/cli.test.d.ts.map +1 -0
- package/dist/cli.test.js +110 -0
- package/dist/cli.test.js.map +1 -0
- package/dist/index.test.d.ts +2 -0
- package/dist/index.test.d.ts.map +1 -0
- package/dist/index.test.js +21 -0
- package/dist/index.test.js.map +1 -0
- package/dist/public/assets/index-BAtN_fgH.css +1 -0
- package/dist/public/assets/index-DgiS5drt.js +91 -0
- package/dist/public/index.html +2 -2
- package/dist/server.d.ts +3 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +308 -30
- package/dist/server.js.map +1 -1
- package/dist/server.test.js +1275 -81
- package/dist/server.test.js.map +1 -1
- package/package.json +5 -3
- package/dist/public/assets/index-B968yTOS.js +0 -91
- package/dist/public/assets/index-Cu9n47_k.css +0 -1
package/dist/server.test.js
CHANGED
|
@@ -1,101 +1,1295 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
-
import
|
|
3
|
-
import
|
|
4
|
-
import
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
2
|
+
import { mkdtempSync, rmSync, existsSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
vi.mock("node:os", async (importOriginal) => {
|
|
6
|
+
const original = await importOriginal();
|
|
7
|
+
return {
|
|
8
|
+
...original,
|
|
9
|
+
homedir: () => process.env._TEST_DIR_ || original.homedir(),
|
|
10
|
+
};
|
|
11
|
+
});
|
|
12
|
+
vi.mock("@opencode-trace/core", async (importOriginal) => {
|
|
13
|
+
const original = await importOriginal();
|
|
14
|
+
return {
|
|
15
|
+
...original,
|
|
16
|
+
store: {
|
|
17
|
+
listSessionsFromBothDirs: vi.fn().mockReturnValue([]),
|
|
18
|
+
listSessionsTreeFromBothDirs: vi.fn().mockReturnValue([]),
|
|
19
|
+
getSessionRecords: vi.fn().mockReturnValue([]),
|
|
20
|
+
getRecord: vi.fn().mockReturnValue(null),
|
|
21
|
+
getSSEStream: vi.fn().mockReturnValue(null),
|
|
22
|
+
readTimelineIndex: vi.fn().mockReturnValue([]),
|
|
23
|
+
getCachedParsed: vi.fn().mockReturnValue(null),
|
|
24
|
+
readSessionMetadata: vi.fn().mockReturnValue(null),
|
|
25
|
+
exportSessionZip: vi.fn().mockResolvedValue(Buffer.from("PK")),
|
|
26
|
+
importSessionZip: vi.fn().mockResolvedValue({
|
|
27
|
+
status: "success",
|
|
28
|
+
importedSessions: [{ sessionId: "x", requestCount: 0, strategy: "none" }],
|
|
29
|
+
}),
|
|
30
|
+
deleteSession: vi.fn().mockResolvedValue(undefined),
|
|
31
|
+
},
|
|
32
|
+
parse: {
|
|
33
|
+
detectAndParse: vi.fn().mockReturnValue({
|
|
34
|
+
provider: "openai-chat",
|
|
35
|
+
model: "gpt-4",
|
|
36
|
+
msgs: [],
|
|
37
|
+
usage: null,
|
|
38
|
+
stream: false,
|
|
39
|
+
}),
|
|
40
|
+
detectProvider: vi.fn().mockReturnValue("openai-chat"),
|
|
41
|
+
extractUsage: vi.fn().mockReturnValue({
|
|
42
|
+
inputMissTokens: 10,
|
|
43
|
+
inputHitTokens: 0,
|
|
44
|
+
outputTokens: 5,
|
|
45
|
+
}),
|
|
46
|
+
extractLatency: vi.fn().mockReturnValue({
|
|
47
|
+
requestSentAt: 1,
|
|
48
|
+
firstTokenAt: 101,
|
|
49
|
+
lastTokenAt: 201,
|
|
50
|
+
ttft: 100,
|
|
51
|
+
tpot: null,
|
|
52
|
+
totalDuration: 200,
|
|
53
|
+
}),
|
|
54
|
+
openaiChatParser: {
|
|
55
|
+
parseRequest: vi.fn().mockReturnValue({
|
|
56
|
+
provider: "openai-chat",
|
|
57
|
+
model: "gpt-4",
|
|
58
|
+
msgs: [],
|
|
59
|
+
usage: null,
|
|
60
|
+
stream: false,
|
|
61
|
+
}),
|
|
62
|
+
parseResponse: vi.fn().mockReturnValue({ msgs: [], usage: null }),
|
|
63
|
+
},
|
|
64
|
+
openaiResponsesParser: {
|
|
65
|
+
parseRequest: vi.fn().mockReturnValue({
|
|
66
|
+
provider: "openai-responses",
|
|
67
|
+
model: "gpt-4",
|
|
68
|
+
msgs: [],
|
|
69
|
+
usage: null,
|
|
70
|
+
stream: false,
|
|
71
|
+
}),
|
|
72
|
+
parseResponse: vi.fn().mockReturnValue({ msgs: [], usage: null }),
|
|
73
|
+
},
|
|
74
|
+
anthropicParser: {
|
|
75
|
+
parseRequest: vi.fn().mockReturnValue({
|
|
76
|
+
provider: "anthropic",
|
|
77
|
+
model: "claude-3",
|
|
78
|
+
msgs: [],
|
|
79
|
+
usage: null,
|
|
80
|
+
stream: false,
|
|
81
|
+
}),
|
|
82
|
+
parseResponse: vi.fn().mockReturnValue({ msgs: [], usage: null }),
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
query: {
|
|
86
|
+
buildSessionTimeline: vi.fn().mockReturnValue({
|
|
87
|
+
sessionId: "x",
|
|
88
|
+
totalRequests: 0,
|
|
89
|
+
changes: [],
|
|
90
|
+
}),
|
|
91
|
+
buildSessionMetadata: vi.fn().mockReturnValue({
|
|
92
|
+
sessionId: "x",
|
|
93
|
+
tokenUsage: {
|
|
94
|
+
inputMissTokens: 0,
|
|
95
|
+
inputHitTokens: 0,
|
|
96
|
+
outputTokens: 0,
|
|
97
|
+
totalTokens: 0,
|
|
98
|
+
cacheHitRate: 0,
|
|
99
|
+
},
|
|
100
|
+
requestCount: 0,
|
|
101
|
+
subSessions: [],
|
|
102
|
+
parentSession: null,
|
|
103
|
+
createdAt: null,
|
|
104
|
+
updatedAt: null,
|
|
105
|
+
latencyStats: null,
|
|
106
|
+
durationStats: null,
|
|
107
|
+
}),
|
|
108
|
+
},
|
|
109
|
+
transform: {
|
|
110
|
+
sseAnthropicToMessages: vi.fn().mockReturnValue([]),
|
|
111
|
+
sseOpenaiChatToMessages: vi.fn().mockReturnValue([]),
|
|
112
|
+
sseOpenaiResponsesToMessages: vi.fn().mockReturnValue([]),
|
|
113
|
+
},
|
|
114
|
+
record: {
|
|
115
|
+
initStateManager: vi.fn().mockResolvedValue(undefined),
|
|
116
|
+
getGlobalTraceEnabled: vi.fn().mockReturnValue(false),
|
|
117
|
+
setGlobalTraceEnabled: vi.fn(),
|
|
118
|
+
},
|
|
119
|
+
getTraceDir: vi.fn().mockReturnValue("/tmp/test-trace"),
|
|
120
|
+
};
|
|
121
|
+
});
|
|
122
|
+
import { createViewer } from "./server.js";
|
|
123
|
+
import { store, parse, transform, record, query, } from "@opencode-trace/core";
|
|
124
|
+
function buildMultipart(boundary, fields, file) {
|
|
125
|
+
const parts = [];
|
|
126
|
+
for (const [name, value] of Object.entries(fields)) {
|
|
127
|
+
parts.push(Buffer.from(`--${boundary}\r\n`));
|
|
128
|
+
parts.push(Buffer.from(`Content-Disposition: form-data; name="${name}"\r\n\r\n`));
|
|
129
|
+
parts.push(Buffer.from(`${value}\r\n`));
|
|
130
|
+
}
|
|
131
|
+
if (file) {
|
|
132
|
+
parts.push(Buffer.from(`--${boundary}\r\n`));
|
|
133
|
+
parts.push(Buffer.from(`Content-Disposition: form-data; name="${file.name}"; filename="${file.filename}"\r\n`));
|
|
134
|
+
parts.push(Buffer.from(`Content-Type: ${file.contentType}\r\n\r\n`));
|
|
135
|
+
parts.push(file.content);
|
|
136
|
+
parts.push(Buffer.from(`\r\n`));
|
|
137
|
+
}
|
|
138
|
+
parts.push(Buffer.from(`--${boundary}--\r\n`));
|
|
139
|
+
return Buffer.concat(parts);
|
|
52
140
|
}
|
|
53
|
-
|
|
54
|
-
|
|
141
|
+
const mockRecord = {
|
|
142
|
+
id: 1,
|
|
143
|
+
purpose: "test",
|
|
144
|
+
requestAt: "2024-01-01T00:00:00.000Z",
|
|
145
|
+
responseAt: "2024-01-01T00:00:01.000Z",
|
|
146
|
+
request: {
|
|
147
|
+
method: "POST",
|
|
148
|
+
url: "https://api.openai.com/v1/chat/completions",
|
|
149
|
+
headers: { "content-type": "application/json" },
|
|
150
|
+
body: { model: "gpt-4", messages: [{ role: "user", content: "hi" }] },
|
|
151
|
+
},
|
|
152
|
+
response: {
|
|
153
|
+
status: 200,
|
|
154
|
+
statusText: "OK",
|
|
155
|
+
headers: {},
|
|
156
|
+
body: { choices: [{ message: { role: "assistant", content: "hello" } }] },
|
|
157
|
+
},
|
|
158
|
+
error: null,
|
|
159
|
+
requestSentAt: 1000,
|
|
160
|
+
firstTokenAt: 1100,
|
|
161
|
+
lastTokenAt: 1200,
|
|
162
|
+
};
|
|
163
|
+
describe("createViewer (real integration)", () => {
|
|
164
|
+
let testDir;
|
|
165
|
+
let instance = null;
|
|
166
|
+
function reapplyMocks() {
|
|
167
|
+
vi.mocked(store.listSessionsFromBothDirs).mockReturnValue([]);
|
|
168
|
+
vi.mocked(store.listSessionsTreeFromBothDirs).mockReturnValue([]);
|
|
169
|
+
vi.mocked(store.getSessionRecords).mockReturnValue([]);
|
|
170
|
+
vi.mocked(store.getRecord).mockReturnValue(null);
|
|
171
|
+
vi.mocked(store.getSSEStream).mockReturnValue(null);
|
|
172
|
+
vi.mocked(store.readTimelineIndex).mockReturnValue([]);
|
|
173
|
+
vi.mocked(store.getCachedParsed).mockReturnValue(null);
|
|
174
|
+
vi.mocked(store.readSessionMetadata).mockReturnValue(null);
|
|
175
|
+
vi.mocked(store.exportSessionZip).mockResolvedValue(Buffer.from("PK"));
|
|
176
|
+
vi.mocked(store.importSessionZip).mockResolvedValue({
|
|
177
|
+
status: "success",
|
|
178
|
+
importedSessions: [
|
|
179
|
+
{ sessionId: "x", requestCount: 0, strategy: "none" },
|
|
180
|
+
],
|
|
181
|
+
});
|
|
182
|
+
vi.mocked(store.deleteSession).mockResolvedValue(undefined);
|
|
183
|
+
vi.mocked(parse.detectAndParse).mockReturnValue({
|
|
184
|
+
provider: "openai-chat",
|
|
185
|
+
model: "gpt-4",
|
|
186
|
+
msgs: [],
|
|
187
|
+
usage: null,
|
|
188
|
+
stream: false,
|
|
189
|
+
});
|
|
190
|
+
vi.mocked(parse.detectProvider).mockReturnValue("openai-chat");
|
|
191
|
+
vi.mocked(parse.extractUsage).mockReturnValue({
|
|
192
|
+
inputMissTokens: 10,
|
|
193
|
+
inputHitTokens: 0,
|
|
194
|
+
outputTokens: 5,
|
|
195
|
+
});
|
|
196
|
+
vi.mocked(parse.extractLatency).mockReturnValue({
|
|
197
|
+
requestSentAt: 1,
|
|
198
|
+
firstTokenAt: 101,
|
|
199
|
+
lastTokenAt: 201,
|
|
200
|
+
ttft: 100,
|
|
201
|
+
tpot: null,
|
|
202
|
+
totalDuration: 200,
|
|
203
|
+
});
|
|
204
|
+
vi.mocked(parse.openaiChatParser.parseRequest).mockReturnValue({
|
|
205
|
+
provider: "openai-chat",
|
|
206
|
+
model: "gpt-4",
|
|
207
|
+
msgs: [],
|
|
208
|
+
usage: null,
|
|
209
|
+
stream: false,
|
|
210
|
+
});
|
|
211
|
+
vi.mocked(parse.openaiResponsesParser.parseRequest).mockReturnValue({
|
|
212
|
+
provider: "openai-responses",
|
|
213
|
+
model: "gpt-4",
|
|
214
|
+
msgs: [],
|
|
215
|
+
usage: null,
|
|
216
|
+
stream: false,
|
|
217
|
+
});
|
|
218
|
+
vi.mocked(parse.anthropicParser.parseRequest).mockReturnValue({
|
|
219
|
+
provider: "anthropic",
|
|
220
|
+
model: "claude-3",
|
|
221
|
+
msgs: [],
|
|
222
|
+
usage: null,
|
|
223
|
+
stream: false,
|
|
224
|
+
});
|
|
225
|
+
vi.mocked(query.buildSessionTimeline).mockReturnValue({
|
|
226
|
+
sessionId: "x",
|
|
227
|
+
totalRequests: 0,
|
|
228
|
+
changes: [],
|
|
229
|
+
});
|
|
230
|
+
vi.mocked(query.buildSessionMetadata).mockReturnValue({
|
|
231
|
+
sessionId: "x",
|
|
232
|
+
tokenUsage: {
|
|
233
|
+
inputMissTokens: 0,
|
|
234
|
+
inputHitTokens: 0,
|
|
235
|
+
outputTokens: 0,
|
|
236
|
+
totalTokens: 0,
|
|
237
|
+
cacheHitRate: 0,
|
|
238
|
+
},
|
|
239
|
+
requestCount: 0,
|
|
240
|
+
subSessions: [],
|
|
241
|
+
parentSession: null,
|
|
242
|
+
createdAt: null,
|
|
243
|
+
updatedAt: null,
|
|
244
|
+
latencyStats: null,
|
|
245
|
+
durationStats: null,
|
|
246
|
+
});
|
|
247
|
+
vi.mocked(transform.sseAnthropicToMessages).mockReturnValue([]);
|
|
248
|
+
vi.mocked(transform.sseOpenaiChatToMessages).mockReturnValue([]);
|
|
249
|
+
vi.mocked(transform.sseOpenaiResponsesToMessages).mockReturnValue([]);
|
|
250
|
+
vi.mocked(record.getGlobalTraceEnabled).mockReturnValue(false);
|
|
251
|
+
}
|
|
55
252
|
beforeEach(() => {
|
|
253
|
+
testDir = mkdtempSync(join(tmpdir(), "viewer-server-test-"));
|
|
56
254
|
vi.clearAllMocks();
|
|
255
|
+
reapplyMocks();
|
|
57
256
|
});
|
|
58
257
|
afterEach(async () => {
|
|
59
|
-
if (
|
|
60
|
-
await
|
|
61
|
-
|
|
258
|
+
if (instance) {
|
|
259
|
+
await instance.close();
|
|
260
|
+
instance = null;
|
|
261
|
+
}
|
|
262
|
+
if (existsSync(testDir)) {
|
|
263
|
+
rmSync(testDir, { recursive: true, force: true });
|
|
62
264
|
}
|
|
63
265
|
});
|
|
64
|
-
describe("
|
|
65
|
-
it("
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
266
|
+
describe("GET /api/sessions", () => {
|
|
267
|
+
it("returns the list from store", async () => {
|
|
268
|
+
const mockSessions = [
|
|
269
|
+
{
|
|
270
|
+
id: "abc",
|
|
271
|
+
requestCount: 1,
|
|
272
|
+
createdAt: "2024-01-01T00:00:00Z",
|
|
273
|
+
updatedAt: "2024-01-01T00:00:01Z",
|
|
274
|
+
scope: "global",
|
|
275
|
+
},
|
|
276
|
+
];
|
|
277
|
+
vi.mocked(store.listSessionsFromBothDirs).mockReturnValue(mockSessions);
|
|
278
|
+
instance = await createViewer({
|
|
279
|
+
port: 0,
|
|
280
|
+
noListen: true,
|
|
281
|
+
traceDir: testDir,
|
|
282
|
+
});
|
|
283
|
+
const response = await instance.app.inject({
|
|
284
|
+
method: "GET",
|
|
285
|
+
url: "/api/sessions",
|
|
286
|
+
});
|
|
287
|
+
expect(response.statusCode).toBe(200);
|
|
288
|
+
expect(response.json()).toEqual(mockSessions);
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
describe("GET /api/sessions/tree", () => {
|
|
292
|
+
it("returns the tree from store", async () => {
|
|
293
|
+
const mockTree = [
|
|
294
|
+
{
|
|
295
|
+
id: "abc",
|
|
296
|
+
requestCount: 1,
|
|
297
|
+
createdAt: null,
|
|
298
|
+
updatedAt: null,
|
|
299
|
+
scope: "global",
|
|
300
|
+
children: [],
|
|
301
|
+
},
|
|
302
|
+
];
|
|
303
|
+
vi.mocked(store.listSessionsTreeFromBothDirs).mockReturnValue(mockTree);
|
|
304
|
+
instance = await createViewer({
|
|
305
|
+
port: 0,
|
|
306
|
+
noListen: true,
|
|
307
|
+
traceDir: testDir,
|
|
308
|
+
});
|
|
309
|
+
const response = await instance.app.inject({
|
|
310
|
+
method: "GET",
|
|
311
|
+
url: "/api/sessions/tree",
|
|
312
|
+
});
|
|
313
|
+
expect(response.statusCode).toBe(200);
|
|
314
|
+
expect(response.json()).toEqual(mockTree);
|
|
315
|
+
});
|
|
316
|
+
});
|
|
317
|
+
describe("GET /api/sessions/:sessionId/timeline", () => {
|
|
318
|
+
it("returns timeline built from cached parsed entries", async () => {
|
|
319
|
+
vi.mocked(store.readSessionMetadata).mockReturnValue({
|
|
320
|
+
sessionId: "abc",
|
|
321
|
+
});
|
|
322
|
+
vi.mocked(store.readTimelineIndex).mockReturnValue([
|
|
323
|
+
{
|
|
324
|
+
seq: 1,
|
|
325
|
+
url: "https://api.openai.com/v1/chat/completions",
|
|
326
|
+
method: "POST",
|
|
327
|
+
purpose: "test",
|
|
328
|
+
requestAt: "2024-01-01T00:00:00.000Z",
|
|
329
|
+
responseAt: "2024-01-01T00:00:01.000Z",
|
|
330
|
+
status: 200,
|
|
331
|
+
provider: "openai-chat",
|
|
332
|
+
model: "gpt-4",
|
|
333
|
+
inputTokens: 10,
|
|
334
|
+
outputTokens: 5,
|
|
335
|
+
totalDurationMs: 200,
|
|
336
|
+
},
|
|
337
|
+
]);
|
|
338
|
+
vi.mocked(store.getCachedParsed).mockReturnValue({
|
|
339
|
+
provider: "openai-chat",
|
|
340
|
+
model: "gpt-4",
|
|
341
|
+
msgs: [],
|
|
342
|
+
usage: null,
|
|
343
|
+
stream: false,
|
|
344
|
+
});
|
|
345
|
+
vi.mocked(query.buildSessionTimeline).mockReturnValue({
|
|
346
|
+
sessionId: "abc",
|
|
347
|
+
totalRequests: 1,
|
|
348
|
+
changes: [],
|
|
349
|
+
});
|
|
350
|
+
instance = await createViewer({
|
|
351
|
+
port: 0,
|
|
352
|
+
noListen: true,
|
|
353
|
+
traceDir: testDir,
|
|
354
|
+
});
|
|
355
|
+
const response = await instance.app.inject({
|
|
356
|
+
method: "GET",
|
|
357
|
+
url: "/api/sessions/abc/timeline",
|
|
358
|
+
});
|
|
359
|
+
expect(response.statusCode).toBe(200);
|
|
360
|
+
const data = response.json();
|
|
361
|
+
expect(data.sessionId).toBe("abc");
|
|
362
|
+
expect(data.totalRequests).toBe(1);
|
|
363
|
+
});
|
|
364
|
+
it("falls back to full record parse when cache is empty", async () => {
|
|
365
|
+
vi.mocked(store.readSessionMetadata).mockReturnValue({
|
|
366
|
+
sessionId: "abc",
|
|
367
|
+
});
|
|
368
|
+
vi.mocked(store.readTimelineIndex).mockReturnValue([
|
|
369
|
+
{
|
|
370
|
+
seq: 1,
|
|
371
|
+
url: "https://api.openai.com/v1/chat/completions",
|
|
372
|
+
method: "POST",
|
|
373
|
+
purpose: "test",
|
|
374
|
+
requestAt: "2024-01-01T00:00:00.000Z",
|
|
375
|
+
responseAt: "2024-01-01T00:00:01.000Z",
|
|
376
|
+
status: 200,
|
|
377
|
+
provider: "openai-chat",
|
|
378
|
+
model: "gpt-4",
|
|
379
|
+
inputTokens: 10,
|
|
380
|
+
outputTokens: 5,
|
|
381
|
+
totalDurationMs: 200,
|
|
382
|
+
},
|
|
383
|
+
]);
|
|
384
|
+
vi.mocked(store.getCachedParsed).mockReturnValue(null);
|
|
385
|
+
vi.mocked(store.getRecord).mockReturnValue(mockRecord);
|
|
386
|
+
instance = await createViewer({
|
|
387
|
+
port: 0,
|
|
388
|
+
noListen: true,
|
|
389
|
+
traceDir: testDir,
|
|
390
|
+
});
|
|
391
|
+
const response = await instance.app.inject({
|
|
392
|
+
method: "GET",
|
|
393
|
+
url: "/api/sessions/abc/timeline",
|
|
394
|
+
});
|
|
395
|
+
expect(response.statusCode).toBe(200);
|
|
396
|
+
expect(parse.detectAndParse).toHaveBeenCalled();
|
|
397
|
+
});
|
|
398
|
+
it("returns 404 when session not found", async () => {
|
|
399
|
+
instance = await createViewer({
|
|
400
|
+
port: 0,
|
|
401
|
+
noListen: true,
|
|
402
|
+
traceDir: testDir,
|
|
403
|
+
});
|
|
404
|
+
const response = await instance.app.inject({
|
|
405
|
+
method: "GET",
|
|
406
|
+
url: "/api/sessions/missing/timeline",
|
|
407
|
+
});
|
|
408
|
+
expect(response.statusCode).toBe(404);
|
|
409
|
+
expect(response.json()).toEqual({ error: "Session not found" });
|
|
410
|
+
});
|
|
411
|
+
it("returns 400 for invalid sessionId", async () => {
|
|
412
|
+
instance = await createViewer({
|
|
413
|
+
port: 0,
|
|
414
|
+
noListen: true,
|
|
415
|
+
traceDir: testDir,
|
|
416
|
+
});
|
|
417
|
+
const response = await instance.app.inject({
|
|
418
|
+
method: "GET",
|
|
419
|
+
url: "/api/sessions/has%20space/timeline",
|
|
420
|
+
});
|
|
421
|
+
expect(response.statusCode).toBe(400);
|
|
422
|
+
});
|
|
423
|
+
});
|
|
424
|
+
describe("GET /api/sessions/:sessionId/metadata", () => {
|
|
425
|
+
it("returns metadata when session exists", async () => {
|
|
426
|
+
vi.mocked(store.readSessionMetadata).mockReturnValue({
|
|
427
|
+
sessionId: "abc",
|
|
428
|
+
});
|
|
429
|
+
vi.mocked(store.readTimelineIndex).mockReturnValue([
|
|
430
|
+
{
|
|
431
|
+
seq: 1,
|
|
432
|
+
url: "https://api.openai.com/v1/chat/completions",
|
|
433
|
+
method: "POST",
|
|
434
|
+
purpose: "test",
|
|
435
|
+
requestAt: "2024-01-01T00:00:00.000Z",
|
|
436
|
+
responseAt: "2024-01-01T00:00:01.000Z",
|
|
437
|
+
status: 200,
|
|
438
|
+
provider: "openai-chat",
|
|
439
|
+
model: "gpt-4",
|
|
440
|
+
inputTokens: 10,
|
|
441
|
+
outputTokens: 5,
|
|
442
|
+
totalDurationMs: 200,
|
|
443
|
+
},
|
|
444
|
+
]);
|
|
445
|
+
vi.mocked(store.getCachedParsed).mockReturnValue({
|
|
446
|
+
provider: "openai-chat",
|
|
447
|
+
model: "gpt-4",
|
|
448
|
+
msgs: [],
|
|
449
|
+
usage: null,
|
|
450
|
+
stream: false,
|
|
451
|
+
});
|
|
452
|
+
vi.mocked(query.buildSessionMetadata).mockReturnValue({
|
|
453
|
+
sessionId: "abc",
|
|
454
|
+
tokenUsage: {
|
|
455
|
+
inputMissTokens: 0,
|
|
456
|
+
inputHitTokens: 0,
|
|
457
|
+
outputTokens: 0,
|
|
458
|
+
totalTokens: 0,
|
|
459
|
+
cacheHitRate: 0,
|
|
460
|
+
},
|
|
461
|
+
requestCount: 1,
|
|
462
|
+
subSessions: [],
|
|
463
|
+
parentSession: null,
|
|
464
|
+
createdAt: "2024-01-01T00:00:00.000Z",
|
|
465
|
+
updatedAt: "2024-01-01T00:00:01.000Z",
|
|
466
|
+
latencyStats: null,
|
|
467
|
+
durationStats: null,
|
|
468
|
+
});
|
|
469
|
+
instance = await createViewer({
|
|
470
|
+
port: 0,
|
|
471
|
+
noListen: true,
|
|
472
|
+
traceDir: testDir,
|
|
473
|
+
});
|
|
474
|
+
const response = await instance.app.inject({
|
|
475
|
+
method: "GET",
|
|
476
|
+
url: "/api/sessions/abc/metadata",
|
|
477
|
+
});
|
|
478
|
+
expect(response.statusCode).toBe(200);
|
|
479
|
+
expect(response.json().sessionId).toBe("abc");
|
|
480
|
+
});
|
|
481
|
+
it("returns 404 when session not found", async () => {
|
|
482
|
+
instance = await createViewer({
|
|
483
|
+
port: 0,
|
|
484
|
+
noListen: true,
|
|
485
|
+
traceDir: testDir,
|
|
486
|
+
});
|
|
487
|
+
const response = await instance.app.inject({
|
|
488
|
+
method: "GET",
|
|
489
|
+
url: "/api/sessions/missing/metadata",
|
|
490
|
+
});
|
|
491
|
+
expect(response.statusCode).toBe(404);
|
|
492
|
+
});
|
|
493
|
+
it("returns 400 for invalid sessionId", async () => {
|
|
494
|
+
instance = await createViewer({
|
|
495
|
+
port: 0,
|
|
496
|
+
noListen: true,
|
|
497
|
+
traceDir: testDir,
|
|
498
|
+
});
|
|
499
|
+
const response = await instance.app.inject({
|
|
500
|
+
method: "GET",
|
|
501
|
+
url: "/api/sessions/bad!id/metadata",
|
|
502
|
+
});
|
|
503
|
+
expect(response.statusCode).toBe(400);
|
|
504
|
+
});
|
|
505
|
+
});
|
|
506
|
+
describe("GET /api/sessions/:sessionId/records/:recordId/parsed", () => {
|
|
507
|
+
it("returns cached parsed when available", async () => {
|
|
508
|
+
vi.mocked(store.readSessionMetadata).mockReturnValue({
|
|
509
|
+
sessionId: "abc",
|
|
510
|
+
});
|
|
511
|
+
vi.mocked(store.getCachedParsed).mockReturnValue({
|
|
512
|
+
provider: "openai-chat",
|
|
513
|
+
model: "gpt-4",
|
|
514
|
+
msgs: [],
|
|
515
|
+
usage: null,
|
|
516
|
+
stream: false,
|
|
517
|
+
});
|
|
518
|
+
instance = await createViewer({
|
|
519
|
+
port: 0,
|
|
520
|
+
noListen: true,
|
|
521
|
+
traceDir: testDir,
|
|
522
|
+
});
|
|
523
|
+
const response = await instance.app.inject({
|
|
524
|
+
method: "GET",
|
|
525
|
+
url: "/api/sessions/abc/records/1/parsed",
|
|
526
|
+
});
|
|
527
|
+
expect(response.statusCode).toBe(200);
|
|
528
|
+
expect(response.json().provider).toBe("openai-chat");
|
|
529
|
+
expect(store.getRecord).not.toHaveBeenCalled();
|
|
530
|
+
});
|
|
531
|
+
it("falls back to detectAndParse when no cache", async () => {
|
|
532
|
+
vi.mocked(store.readSessionMetadata).mockReturnValue({
|
|
533
|
+
sessionId: "abc",
|
|
534
|
+
});
|
|
535
|
+
vi.mocked(store.getCachedParsed).mockReturnValue(null);
|
|
536
|
+
vi.mocked(store.getRecord).mockReturnValue(mockRecord);
|
|
537
|
+
instance = await createViewer({
|
|
538
|
+
port: 0,
|
|
539
|
+
noListen: true,
|
|
540
|
+
traceDir: testDir,
|
|
541
|
+
});
|
|
542
|
+
const response = await instance.app.inject({
|
|
543
|
+
method: "GET",
|
|
544
|
+
url: "/api/sessions/abc/records/1/parsed",
|
|
545
|
+
});
|
|
546
|
+
expect(response.statusCode).toBe(200);
|
|
547
|
+
expect(parse.detectAndParse).toHaveBeenCalled();
|
|
548
|
+
});
|
|
549
|
+
it("returns 404 when record not found", async () => {
|
|
550
|
+
vi.mocked(store.readSessionMetadata).mockReturnValue({
|
|
551
|
+
sessionId: "abc",
|
|
552
|
+
});
|
|
553
|
+
vi.mocked(store.getRecord).mockReturnValue(null);
|
|
554
|
+
instance = await createViewer({
|
|
555
|
+
port: 0,
|
|
556
|
+
noListen: true,
|
|
557
|
+
traceDir: testDir,
|
|
558
|
+
});
|
|
559
|
+
const response = await instance.app.inject({
|
|
560
|
+
method: "GET",
|
|
561
|
+
url: "/api/sessions/abc/records/99/parsed",
|
|
562
|
+
});
|
|
563
|
+
expect(response.statusCode).toBe(404);
|
|
564
|
+
});
|
|
565
|
+
it("returns 400 for invalid recordId", async () => {
|
|
566
|
+
instance = await createViewer({
|
|
567
|
+
port: 0,
|
|
568
|
+
noListen: true,
|
|
569
|
+
traceDir: testDir,
|
|
570
|
+
});
|
|
571
|
+
const response = await instance.app.inject({
|
|
572
|
+
method: "GET",
|
|
573
|
+
url: "/api/sessions/abc/records/0/parsed",
|
|
574
|
+
});
|
|
575
|
+
expect(response.statusCode).toBe(400);
|
|
576
|
+
});
|
|
577
|
+
});
|
|
578
|
+
describe("GET /api/sessions/:sessionId/records/:recordId/usage", () => {
|
|
579
|
+
it("returns usage for a valid record", async () => {
|
|
580
|
+
vi.mocked(store.readSessionMetadata).mockReturnValue({
|
|
581
|
+
sessionId: "abc",
|
|
582
|
+
});
|
|
583
|
+
vi.mocked(store.getRecord).mockReturnValue(mockRecord);
|
|
584
|
+
instance = await createViewer({
|
|
585
|
+
port: 0,
|
|
586
|
+
noListen: true,
|
|
587
|
+
traceDir: testDir,
|
|
588
|
+
});
|
|
589
|
+
const response = await instance.app.inject({
|
|
590
|
+
method: "GET",
|
|
591
|
+
url: "/api/sessions/abc/records/1/usage",
|
|
592
|
+
});
|
|
593
|
+
expect(response.statusCode).toBe(200);
|
|
594
|
+
expect(response.json().outputTokens).toBe(5);
|
|
595
|
+
});
|
|
596
|
+
it("returns 404 when record not found", async () => {
|
|
597
|
+
vi.mocked(store.readSessionMetadata).mockReturnValue({
|
|
598
|
+
sessionId: "abc",
|
|
599
|
+
});
|
|
600
|
+
vi.mocked(store.getRecord).mockReturnValue(null);
|
|
601
|
+
instance = await createViewer({
|
|
602
|
+
port: 0,
|
|
603
|
+
noListen: true,
|
|
604
|
+
traceDir: testDir,
|
|
605
|
+
});
|
|
606
|
+
const response = await instance.app.inject({
|
|
607
|
+
method: "GET",
|
|
608
|
+
url: "/api/sessions/abc/records/99/usage",
|
|
609
|
+
});
|
|
610
|
+
expect(response.statusCode).toBe(404);
|
|
611
|
+
});
|
|
612
|
+
});
|
|
613
|
+
describe("GET /api/sessions/:sessionId/records/:recordId/latency", () => {
|
|
614
|
+
it("returns latency for a streaming record", async () => {
|
|
615
|
+
vi.mocked(store.readSessionMetadata).mockReturnValue({
|
|
616
|
+
sessionId: "abc",
|
|
617
|
+
});
|
|
618
|
+
vi.mocked(store.getRecord).mockReturnValue(mockRecord);
|
|
619
|
+
instance = await createViewer({
|
|
620
|
+
port: 0,
|
|
621
|
+
noListen: true,
|
|
622
|
+
traceDir: testDir,
|
|
623
|
+
});
|
|
624
|
+
const response = await instance.app.inject({
|
|
625
|
+
method: "GET",
|
|
626
|
+
url: "/api/sessions/abc/records/1/latency",
|
|
627
|
+
});
|
|
628
|
+
expect(response.statusCode).toBe(200);
|
|
629
|
+
const data = response.json();
|
|
630
|
+
expect(data.ttft).toBe(100);
|
|
631
|
+
expect(data.totalDuration).toBe(200);
|
|
632
|
+
});
|
|
633
|
+
it("returns no-latency error when extractLatency returns null", async () => {
|
|
634
|
+
vi.mocked(store.readSessionMetadata).mockReturnValue({
|
|
635
|
+
sessionId: "abc",
|
|
636
|
+
});
|
|
637
|
+
vi.mocked(store.getRecord).mockReturnValue(mockRecord);
|
|
638
|
+
vi.mocked(parse.extractLatency).mockReturnValue(null);
|
|
639
|
+
instance = await createViewer({
|
|
640
|
+
port: 0,
|
|
641
|
+
noListen: true,
|
|
642
|
+
traceDir: testDir,
|
|
643
|
+
});
|
|
644
|
+
const response = await instance.app.inject({
|
|
645
|
+
method: "GET",
|
|
646
|
+
url: "/api/sessions/abc/records/1/latency",
|
|
647
|
+
});
|
|
648
|
+
expect(response.statusCode).toBe(200);
|
|
649
|
+
expect(response.json()).toEqual({ error: "No latency data available" });
|
|
650
|
+
});
|
|
651
|
+
it("returns 404 when record not found", async () => {
|
|
652
|
+
vi.mocked(store.readSessionMetadata).mockReturnValue({
|
|
653
|
+
sessionId: "abc",
|
|
654
|
+
});
|
|
655
|
+
vi.mocked(store.getRecord).mockReturnValue(null);
|
|
656
|
+
instance = await createViewer({
|
|
657
|
+
port: 0,
|
|
658
|
+
noListen: true,
|
|
659
|
+
traceDir: testDir,
|
|
660
|
+
});
|
|
661
|
+
const response = await instance.app.inject({
|
|
662
|
+
method: "GET",
|
|
663
|
+
url: "/api/sessions/abc/records/99/latency",
|
|
664
|
+
});
|
|
665
|
+
expect(response.statusCode).toBe(404);
|
|
666
|
+
});
|
|
667
|
+
});
|
|
668
|
+
describe("GET /api/sessions/:sessionId/records/:recordId/sse", () => {
|
|
669
|
+
it("returns raw + messages for an openai-chat record", async () => {
|
|
670
|
+
vi.mocked(store.readSessionMetadata).mockReturnValue({
|
|
671
|
+
sessionId: "abc",
|
|
672
|
+
});
|
|
673
|
+
vi.mocked(store.getRecord).mockReturnValue(mockRecord);
|
|
674
|
+
vi.mocked(store.getSSEStream).mockReturnValue("data: hello\n\n");
|
|
675
|
+
vi.mocked(parse.detectProvider).mockReturnValue("openai-chat");
|
|
676
|
+
instance = await createViewer({
|
|
677
|
+
port: 0,
|
|
678
|
+
noListen: true,
|
|
679
|
+
traceDir: testDir,
|
|
680
|
+
});
|
|
681
|
+
const response = await instance.app.inject({
|
|
682
|
+
method: "GET",
|
|
683
|
+
url: "/api/sessions/abc/records/1/sse",
|
|
684
|
+
});
|
|
685
|
+
expect(response.statusCode).toBe(200);
|
|
686
|
+
const data = response.json();
|
|
687
|
+
expect(data.raw).toBe("data: hello\n\n");
|
|
688
|
+
expect(transform.sseOpenaiChatToMessages).toHaveBeenCalled();
|
|
689
|
+
});
|
|
690
|
+
it("uses sseAnthropicToMessages for anthropic provider", async () => {
|
|
691
|
+
vi.mocked(store.readSessionMetadata).mockReturnValue({
|
|
692
|
+
sessionId: "abc",
|
|
693
|
+
});
|
|
694
|
+
vi.mocked(store.getRecord).mockReturnValue(mockRecord);
|
|
695
|
+
vi.mocked(store.getSSEStream).mockReturnValue("event: message_start\n\n");
|
|
696
|
+
vi.mocked(parse.detectProvider).mockReturnValue("anthropic");
|
|
697
|
+
instance = await createViewer({
|
|
698
|
+
port: 0,
|
|
699
|
+
noListen: true,
|
|
700
|
+
traceDir: testDir,
|
|
701
|
+
});
|
|
702
|
+
await instance.app.inject({
|
|
703
|
+
method: "GET",
|
|
704
|
+
url: "/api/sessions/abc/records/1/sse",
|
|
705
|
+
});
|
|
706
|
+
expect(transform.sseAnthropicToMessages).toHaveBeenCalled();
|
|
707
|
+
});
|
|
708
|
+
it("uses sseOpenaiResponsesToMessages for openai-responses provider", async () => {
|
|
709
|
+
vi.mocked(store.readSessionMetadata).mockReturnValue({
|
|
710
|
+
sessionId: "abc",
|
|
711
|
+
});
|
|
712
|
+
vi.mocked(store.getRecord).mockReturnValue(mockRecord);
|
|
713
|
+
vi.mocked(store.getSSEStream).mockReturnValue("event: response.created\n\n");
|
|
714
|
+
vi.mocked(parse.detectProvider).mockReturnValue("openai-responses");
|
|
715
|
+
instance = await createViewer({
|
|
716
|
+
port: 0,
|
|
717
|
+
noListen: true,
|
|
718
|
+
traceDir: testDir,
|
|
719
|
+
});
|
|
720
|
+
await instance.app.inject({
|
|
721
|
+
method: "GET",
|
|
722
|
+
url: "/api/sessions/abc/records/1/sse",
|
|
723
|
+
});
|
|
724
|
+
expect(transform.sseOpenaiResponsesToMessages).toHaveBeenCalled();
|
|
725
|
+
});
|
|
726
|
+
it("returns 404 when no SSE data", async () => {
|
|
727
|
+
vi.mocked(store.readSessionMetadata).mockReturnValue({
|
|
728
|
+
sessionId: "abc",
|
|
729
|
+
});
|
|
730
|
+
vi.mocked(store.getSSEStream).mockReturnValue(null);
|
|
731
|
+
instance = await createViewer({
|
|
732
|
+
port: 0,
|
|
733
|
+
noListen: true,
|
|
734
|
+
traceDir: testDir,
|
|
735
|
+
});
|
|
736
|
+
const response = await instance.app.inject({
|
|
737
|
+
method: "GET",
|
|
738
|
+
url: "/api/sessions/abc/records/1/sse",
|
|
739
|
+
});
|
|
740
|
+
expect(response.statusCode).toBe(404);
|
|
741
|
+
});
|
|
742
|
+
});
|
|
743
|
+
describe("GET /api/sessions/:sessionId/records/:recordId", () => {
|
|
744
|
+
it("returns the full record", async () => {
|
|
745
|
+
vi.mocked(store.readSessionMetadata).mockReturnValue({
|
|
746
|
+
sessionId: "abc",
|
|
747
|
+
});
|
|
748
|
+
vi.mocked(store.getRecord).mockReturnValue(mockRecord);
|
|
749
|
+
instance = await createViewer({
|
|
750
|
+
port: 0,
|
|
751
|
+
noListen: true,
|
|
752
|
+
traceDir: testDir,
|
|
753
|
+
});
|
|
754
|
+
const response = await instance.app.inject({
|
|
755
|
+
method: "GET",
|
|
756
|
+
url: "/api/sessions/abc/records/1",
|
|
757
|
+
});
|
|
758
|
+
expect(response.statusCode).toBe(200);
|
|
759
|
+
expect(response.json().id).toBe(1);
|
|
760
|
+
});
|
|
761
|
+
it("returns 404 when record not found", async () => {
|
|
762
|
+
vi.mocked(store.readSessionMetadata).mockReturnValue({
|
|
763
|
+
sessionId: "abc",
|
|
764
|
+
});
|
|
765
|
+
vi.mocked(store.getRecord).mockReturnValue(null);
|
|
766
|
+
instance = await createViewer({
|
|
767
|
+
port: 0,
|
|
768
|
+
noListen: true,
|
|
769
|
+
traceDir: testDir,
|
|
770
|
+
});
|
|
771
|
+
const response = await instance.app.inject({
|
|
772
|
+
method: "GET",
|
|
773
|
+
url: "/api/sessions/abc/records/99",
|
|
774
|
+
});
|
|
775
|
+
expect(response.statusCode).toBe(404);
|
|
776
|
+
});
|
|
777
|
+
});
|
|
778
|
+
describe("GET /api/sessions/:sessionId", () => {
|
|
779
|
+
it("returns enriched session + records when session exists", async () => {
|
|
780
|
+
const mockSession = {
|
|
781
|
+
id: "abc",
|
|
782
|
+
requestCount: 2,
|
|
783
|
+
createdAt: "2024-01-01T00:00:00Z",
|
|
784
|
+
updatedAt: "2024-01-01T00:00:01Z",
|
|
785
|
+
scope: "global",
|
|
77
786
|
};
|
|
78
|
-
vi.
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
787
|
+
vi.mocked(store.readSessionMetadata).mockReturnValue({
|
|
788
|
+
sessionId: "abc",
|
|
789
|
+
});
|
|
790
|
+
vi.mocked(store.listSessionsFromBothDirs).mockReturnValue([mockSession]);
|
|
791
|
+
vi.mocked(store.getSessionRecords).mockReturnValue([mockRecord]);
|
|
792
|
+
instance = await createViewer({
|
|
793
|
+
port: 0,
|
|
794
|
+
noListen: true,
|
|
795
|
+
traceDir: testDir,
|
|
796
|
+
});
|
|
797
|
+
const response = await instance.app.inject({
|
|
798
|
+
method: "GET",
|
|
799
|
+
url: "/api/sessions/abc",
|
|
800
|
+
});
|
|
801
|
+
expect(response.statusCode).toBe(200);
|
|
802
|
+
const data = response.json();
|
|
803
|
+
expect(data.session.id).toBe("abc");
|
|
804
|
+
expect(data.records).toHaveLength(1);
|
|
805
|
+
expect(data.records[0].provider).toBe("openai-chat");
|
|
806
|
+
});
|
|
807
|
+
it("returns placeholder session meta when session meta missing", async () => {
|
|
808
|
+
vi.mocked(store.listSessionsFromBothDirs).mockReturnValue([]);
|
|
809
|
+
instance = await createViewer({
|
|
810
|
+
port: 0,
|
|
811
|
+
noListen: true,
|
|
812
|
+
traceDir: testDir,
|
|
813
|
+
});
|
|
814
|
+
const response = await instance.app.inject({
|
|
815
|
+
method: "GET",
|
|
816
|
+
url: "/api/sessions/abc",
|
|
817
|
+
});
|
|
818
|
+
expect(response.statusCode).toBe(200);
|
|
819
|
+
const data = response.json();
|
|
820
|
+
expect(data.session.id).toBe("abc");
|
|
821
|
+
expect(data.records).toEqual([]);
|
|
822
|
+
});
|
|
823
|
+
});
|
|
824
|
+
describe("GET /api/trace/status", () => {
|
|
825
|
+
it("returns globalEnabled flag", async () => {
|
|
826
|
+
vi.mocked(record.getGlobalTraceEnabled).mockReturnValue(true);
|
|
827
|
+
instance = await createViewer({
|
|
828
|
+
port: 0,
|
|
829
|
+
noListen: true,
|
|
830
|
+
traceDir: testDir,
|
|
831
|
+
});
|
|
832
|
+
const response = await instance.app.inject({
|
|
833
|
+
method: "GET",
|
|
834
|
+
url: "/api/trace/status",
|
|
835
|
+
});
|
|
836
|
+
expect(response.statusCode).toBe(200);
|
|
837
|
+
expect(response.json()).toEqual({ globalEnabled: true });
|
|
838
|
+
});
|
|
839
|
+
});
|
|
840
|
+
describe("GET /api/trace/enable", () => {
|
|
841
|
+
it("enables tracing and returns success", async () => {
|
|
842
|
+
instance = await createViewer({
|
|
843
|
+
port: 0,
|
|
844
|
+
noListen: true,
|
|
845
|
+
traceDir: testDir,
|
|
846
|
+
});
|
|
847
|
+
const response = await instance.app.inject({
|
|
848
|
+
method: "GET",
|
|
849
|
+
url: "/api/trace/enable",
|
|
850
|
+
});
|
|
851
|
+
expect(response.statusCode).toBe(200);
|
|
852
|
+
expect(response.json()).toEqual({ success: true, globalEnabled: true });
|
|
853
|
+
expect(record.setGlobalTraceEnabled).toHaveBeenCalledWith(true, testDir);
|
|
854
|
+
});
|
|
855
|
+
});
|
|
856
|
+
describe("GET /api/trace/disable", () => {
|
|
857
|
+
it("disables tracing and returns success", async () => {
|
|
858
|
+
instance = await createViewer({
|
|
859
|
+
port: 0,
|
|
860
|
+
noListen: true,
|
|
861
|
+
traceDir: testDir,
|
|
862
|
+
});
|
|
863
|
+
const response = await instance.app.inject({
|
|
864
|
+
method: "GET",
|
|
865
|
+
url: "/api/trace/disable",
|
|
866
|
+
});
|
|
867
|
+
expect(response.statusCode).toBe(200);
|
|
868
|
+
expect(response.json()).toEqual({ success: true, globalEnabled: false });
|
|
869
|
+
expect(record.setGlobalTraceEnabled).toHaveBeenCalledWith(false, testDir);
|
|
870
|
+
});
|
|
871
|
+
});
|
|
872
|
+
describe("GET /api/trace-dir", () => {
|
|
873
|
+
it("returns globalDir and localDir", async () => {
|
|
874
|
+
instance = await createViewer({
|
|
875
|
+
port: 0,
|
|
876
|
+
noListen: true,
|
|
877
|
+
traceDir: testDir,
|
|
878
|
+
});
|
|
879
|
+
const response = await instance.app.inject({
|
|
82
880
|
method: "GET",
|
|
83
|
-
url: "/api/
|
|
881
|
+
url: "/api/trace-dir",
|
|
882
|
+
});
|
|
883
|
+
expect(response.statusCode).toBe(200);
|
|
884
|
+
const data = response.json();
|
|
885
|
+
expect(data.traceDir).toBe(testDir);
|
|
886
|
+
expect(data.localDir).toBe(testDir);
|
|
887
|
+
});
|
|
888
|
+
});
|
|
889
|
+
describe("GET /api/events (SSE)", () => {
|
|
890
|
+
it("writes connected event with clientId on raw stream", async () => {
|
|
891
|
+
const realInstance = await createViewer({
|
|
892
|
+
port: 0,
|
|
893
|
+
noListen: false,
|
|
894
|
+
traceDir: testDir,
|
|
895
|
+
});
|
|
896
|
+
try {
|
|
897
|
+
const addr = realInstance.app.server.address();
|
|
898
|
+
const port = typeof addr === "object" && addr ? addr.port : Number(realInstance.url.split(":").pop());
|
|
899
|
+
const http = await import("node:http");
|
|
900
|
+
const body = await new Promise((resolve, reject) => {
|
|
901
|
+
const req = http.request({
|
|
902
|
+
hostname: "127.0.0.1",
|
|
903
|
+
port,
|
|
904
|
+
path: "/api/events",
|
|
905
|
+
method: "GET",
|
|
906
|
+
headers: { Accept: "text/event-stream" },
|
|
907
|
+
}, (res) => {
|
|
908
|
+
let data = "";
|
|
909
|
+
res.on("data", (chunk) => {
|
|
910
|
+
data += chunk.toString("utf-8");
|
|
911
|
+
if (data.includes("\n\n")) {
|
|
912
|
+
req.destroy();
|
|
913
|
+
resolve(data);
|
|
914
|
+
}
|
|
915
|
+
});
|
|
916
|
+
res.on("error", () => resolve(data));
|
|
917
|
+
});
|
|
918
|
+
req.on("error", (e) => reject(e));
|
|
919
|
+
req.end();
|
|
920
|
+
setTimeout(() => {
|
|
921
|
+
req.destroy();
|
|
922
|
+
reject(new Error("SSE timeout"));
|
|
923
|
+
}, 3000);
|
|
924
|
+
});
|
|
925
|
+
expect(body).toContain("event: connected");
|
|
926
|
+
expect(body).toMatch(/"clientId":"\d+"/);
|
|
927
|
+
}
|
|
928
|
+
finally {
|
|
929
|
+
await realInstance.close();
|
|
930
|
+
}
|
|
931
|
+
});
|
|
932
|
+
});
|
|
933
|
+
describe("POST /api/sessions/:sessionId/export", () => {
|
|
934
|
+
it("returns the ZIP buffer with proper content-type", async () => {
|
|
935
|
+
vi.mocked(store.readSessionMetadata).mockReturnValue({
|
|
936
|
+
sessionId: "abc",
|
|
937
|
+
});
|
|
938
|
+
vi.mocked(store.exportSessionZip).mockResolvedValue(Buffer.from("PK\x03\x04hello"));
|
|
939
|
+
instance = await createViewer({
|
|
940
|
+
port: 0,
|
|
941
|
+
noListen: true,
|
|
942
|
+
traceDir: testDir,
|
|
943
|
+
});
|
|
944
|
+
const response = await instance.app.inject({
|
|
945
|
+
method: "POST",
|
|
946
|
+
url: "/api/sessions/abc/export",
|
|
947
|
+
});
|
|
948
|
+
expect(response.statusCode).toBe(200);
|
|
949
|
+
expect(response.headers["content-type"]).toContain("application/zip");
|
|
950
|
+
expect(response.headers["content-disposition"]).toContain('filename="session-abc.zip"');
|
|
951
|
+
});
|
|
952
|
+
it("returns 404 when session not found", async () => {
|
|
953
|
+
instance = await createViewer({
|
|
954
|
+
port: 0,
|
|
955
|
+
noListen: true,
|
|
956
|
+
traceDir: testDir,
|
|
957
|
+
});
|
|
958
|
+
const response = await instance.app.inject({
|
|
959
|
+
method: "POST",
|
|
960
|
+
url: "/api/sessions/missing/export",
|
|
961
|
+
});
|
|
962
|
+
expect(response.statusCode).toBe(404);
|
|
963
|
+
expect(response.json()).toEqual({ error: "Session not found" });
|
|
964
|
+
});
|
|
965
|
+
it("returns 500 when export throws generic error", async () => {
|
|
966
|
+
vi.mocked(store.readSessionMetadata).mockReturnValue({
|
|
967
|
+
sessionId: "abc",
|
|
968
|
+
});
|
|
969
|
+
vi.mocked(store.exportSessionZip).mockRejectedValue(new Error("disk exploded"));
|
|
970
|
+
instance = await createViewer({
|
|
971
|
+
port: 0,
|
|
972
|
+
noListen: true,
|
|
973
|
+
traceDir: testDir,
|
|
974
|
+
});
|
|
975
|
+
const response = await instance.app.inject({
|
|
976
|
+
method: "POST",
|
|
977
|
+
url: "/api/sessions/abc/export",
|
|
978
|
+
});
|
|
979
|
+
expect(response.statusCode).toBe(500);
|
|
980
|
+
expect(response.json().error).toContain("disk exploded");
|
|
981
|
+
});
|
|
982
|
+
});
|
|
983
|
+
describe("POST /api/sessions/import", () => {
|
|
984
|
+
it("imports a session with conflict strategy=rename", async () => {
|
|
985
|
+
const boundary = "----formdata-test-1234";
|
|
986
|
+
const file = Buffer.from("PK\x03\x04zip content");
|
|
987
|
+
const body = buildMultipart(boundary, { conflictStrategy: "rename" }, {
|
|
988
|
+
name: "file",
|
|
989
|
+
filename: "test.zip",
|
|
990
|
+
contentType: "application/zip",
|
|
991
|
+
content: file,
|
|
992
|
+
});
|
|
993
|
+
instance = await createViewer({
|
|
994
|
+
port: 0,
|
|
995
|
+
noListen: true,
|
|
996
|
+
traceDir: testDir,
|
|
997
|
+
});
|
|
998
|
+
const response = await instance.app.inject({
|
|
999
|
+
method: "POST",
|
|
1000
|
+
url: "/api/sessions/import",
|
|
1001
|
+
headers: {
|
|
1002
|
+
"content-type": `multipart/form-data; boundary=${boundary}`,
|
|
1003
|
+
},
|
|
1004
|
+
payload: body,
|
|
1005
|
+
});
|
|
1006
|
+
expect(response.statusCode).toBe(200);
|
|
1007
|
+
expect(response.json().status).toBe("success");
|
|
1008
|
+
expect(store.importSessionZip).toHaveBeenCalledWith(file, expect.objectContaining({ conflictStrategy: "rename" }));
|
|
1009
|
+
});
|
|
1010
|
+
it("returns 400 for invalid conflict strategy", async () => {
|
|
1011
|
+
const boundary = "----formdata-test-1234";
|
|
1012
|
+
const file = Buffer.from("PK\x03\x04zip content");
|
|
1013
|
+
const body = buildMultipart(boundary, { conflictStrategy: "bogus" }, {
|
|
1014
|
+
name: "file",
|
|
1015
|
+
filename: "test.zip",
|
|
1016
|
+
contentType: "application/zip",
|
|
1017
|
+
content: file,
|
|
1018
|
+
});
|
|
1019
|
+
instance = await createViewer({
|
|
1020
|
+
port: 0,
|
|
1021
|
+
noListen: true,
|
|
1022
|
+
traceDir: testDir,
|
|
1023
|
+
});
|
|
1024
|
+
const response = await instance.app.inject({
|
|
1025
|
+
method: "POST",
|
|
1026
|
+
url: "/api/sessions/import",
|
|
1027
|
+
headers: {
|
|
1028
|
+
"content-type": `multipart/form-data; boundary=${boundary}`,
|
|
1029
|
+
},
|
|
1030
|
+
payload: body,
|
|
1031
|
+
});
|
|
1032
|
+
expect(response.statusCode).toBe(400);
|
|
1033
|
+
});
|
|
1034
|
+
it("returns 400 when no file is provided", async () => {
|
|
1035
|
+
const boundary = "----formdata-test-1234";
|
|
1036
|
+
const body = buildMultipart(boundary, { conflictStrategy: "rename" });
|
|
1037
|
+
instance = await createViewer({
|
|
1038
|
+
port: 0,
|
|
1039
|
+
noListen: true,
|
|
1040
|
+
traceDir: testDir,
|
|
1041
|
+
});
|
|
1042
|
+
const response = await instance.app.inject({
|
|
1043
|
+
method: "POST",
|
|
1044
|
+
url: "/api/sessions/import",
|
|
1045
|
+
headers: {
|
|
1046
|
+
"content-type": `multipart/form-data; boundary=${boundary}`,
|
|
1047
|
+
},
|
|
1048
|
+
payload: body,
|
|
1049
|
+
});
|
|
1050
|
+
expect(response.statusCode).toBe(400);
|
|
1051
|
+
});
|
|
1052
|
+
});
|
|
1053
|
+
describe("POST /api/sessions/:sessionId/delete", () => {
|
|
1054
|
+
it("deletes the session and returns success", async () => {
|
|
1055
|
+
vi.mocked(store.readSessionMetadata).mockReturnValue({
|
|
1056
|
+
sessionId: "abc",
|
|
1057
|
+
});
|
|
1058
|
+
instance = await createViewer({
|
|
1059
|
+
port: 0,
|
|
1060
|
+
noListen: true,
|
|
1061
|
+
traceDir: testDir,
|
|
1062
|
+
});
|
|
1063
|
+
const response = await instance.app.inject({
|
|
1064
|
+
method: "POST",
|
|
1065
|
+
url: "/api/sessions/abc/delete",
|
|
1066
|
+
});
|
|
1067
|
+
expect(response.statusCode).toBe(200);
|
|
1068
|
+
expect(response.json()).toEqual({ success: true, sessionId: "abc" });
|
|
1069
|
+
expect(store.deleteSession).toHaveBeenCalledWith("abc", expect.objectContaining({ traceDir: testDir }));
|
|
1070
|
+
});
|
|
1071
|
+
it("returns 404 when session not found", async () => {
|
|
1072
|
+
instance = await createViewer({
|
|
1073
|
+
port: 0,
|
|
1074
|
+
noListen: true,
|
|
1075
|
+
traceDir: testDir,
|
|
1076
|
+
});
|
|
1077
|
+
const response = await instance.app.inject({
|
|
1078
|
+
method: "POST",
|
|
1079
|
+
url: "/api/sessions/missing/delete",
|
|
1080
|
+
});
|
|
1081
|
+
expect(response.statusCode).toBe(404);
|
|
1082
|
+
});
|
|
1083
|
+
it("returns 400 for invalid sessionId", async () => {
|
|
1084
|
+
instance = await createViewer({
|
|
1085
|
+
port: 0,
|
|
1086
|
+
noListen: true,
|
|
1087
|
+
traceDir: testDir,
|
|
1088
|
+
});
|
|
1089
|
+
const response = await instance.app.inject({
|
|
1090
|
+
method: "POST",
|
|
1091
|
+
url: "/api/sessions/bad%21id/delete",
|
|
1092
|
+
});
|
|
1093
|
+
expect(response.statusCode).toBe(400);
|
|
1094
|
+
});
|
|
1095
|
+
});
|
|
1096
|
+
describe("POST /api/sessions/batch-delete", () => {
|
|
1097
|
+
it("returns mixed success/failure for batch", async () => {
|
|
1098
|
+
vi.mocked(store.readSessionMetadata).mockImplementation(((id) => id === "exists" ? { sessionId: id } : null));
|
|
1099
|
+
instance = await createViewer({
|
|
1100
|
+
port: 0,
|
|
1101
|
+
noListen: true,
|
|
1102
|
+
traceDir: testDir,
|
|
1103
|
+
});
|
|
1104
|
+
const response = await instance.app.inject({
|
|
1105
|
+
method: "POST",
|
|
1106
|
+
url: "/api/sessions/batch-delete",
|
|
1107
|
+
payload: { sessionIds: ["exists", "missing"] },
|
|
84
1108
|
});
|
|
85
1109
|
expect(response.statusCode).toBe(200);
|
|
86
1110
|
const data = response.json();
|
|
87
|
-
expect(data.
|
|
88
|
-
expect(data.
|
|
1111
|
+
expect(data.deleted).toEqual(["exists"]);
|
|
1112
|
+
expect(data.errors).toEqual([
|
|
1113
|
+
{ sessionId: "missing", error: "Session not found" },
|
|
1114
|
+
]);
|
|
1115
|
+
});
|
|
1116
|
+
it("returns 400 for empty array", async () => {
|
|
1117
|
+
instance = await createViewer({
|
|
1118
|
+
port: 0,
|
|
1119
|
+
noListen: true,
|
|
1120
|
+
traceDir: testDir,
|
|
1121
|
+
});
|
|
1122
|
+
const response = await instance.app.inject({
|
|
1123
|
+
method: "POST",
|
|
1124
|
+
url: "/api/sessions/batch-delete",
|
|
1125
|
+
payload: { sessionIds: [] },
|
|
1126
|
+
});
|
|
1127
|
+
expect(response.statusCode).toBe(400);
|
|
1128
|
+
});
|
|
1129
|
+
it("returns 400 for missing body", async () => {
|
|
1130
|
+
instance = await createViewer({
|
|
1131
|
+
port: 0,
|
|
1132
|
+
noListen: true,
|
|
1133
|
+
traceDir: testDir,
|
|
1134
|
+
});
|
|
1135
|
+
const response = await instance.app.inject({
|
|
1136
|
+
method: "POST",
|
|
1137
|
+
url: "/api/sessions/batch-delete",
|
|
1138
|
+
payload: {},
|
|
1139
|
+
});
|
|
1140
|
+
expect(response.statusCode).toBe(400);
|
|
1141
|
+
});
|
|
1142
|
+
});
|
|
1143
|
+
describe("setNotFoundHandler", () => {
|
|
1144
|
+
it("returns 404 JSON when public dir is not built", async () => {
|
|
1145
|
+
instance = await createViewer({
|
|
1146
|
+
port: 0,
|
|
1147
|
+
noListen: true,
|
|
1148
|
+
traceDir: testDir,
|
|
1149
|
+
});
|
|
1150
|
+
const response = await instance.app.inject({
|
|
1151
|
+
method: "GET",
|
|
1152
|
+
url: "/some/random/path",
|
|
1153
|
+
});
|
|
1154
|
+
expect(response.statusCode).toBe(404);
|
|
1155
|
+
expect(response.json()).toEqual({ error: "Not found" });
|
|
1156
|
+
});
|
|
1157
|
+
});
|
|
1158
|
+
describe("validateSessionId (via 400 responses)", () => {
|
|
1159
|
+
it("rejects empty sessionId (in URL path)", async () => {
|
|
1160
|
+
instance = await createViewer({
|
|
1161
|
+
port: 0,
|
|
1162
|
+
noListen: true,
|
|
1163
|
+
traceDir: testDir,
|
|
1164
|
+
});
|
|
1165
|
+
const response = await instance.app.inject({
|
|
1166
|
+
method: "GET",
|
|
1167
|
+
url: "/api/sessions//timeline",
|
|
1168
|
+
});
|
|
1169
|
+
expect(response.statusCode).toBeGreaterThanOrEqual(400);
|
|
89
1170
|
});
|
|
90
|
-
it("
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
1171
|
+
it("rejects sessionId with special characters", async () => {
|
|
1172
|
+
instance = await createViewer({
|
|
1173
|
+
port: 0,
|
|
1174
|
+
noListen: true,
|
|
1175
|
+
traceDir: testDir,
|
|
1176
|
+
});
|
|
1177
|
+
const response = await instance.app.inject({
|
|
1178
|
+
method: "GET",
|
|
1179
|
+
url: "/api/sessions/foo%24bar/timeline",
|
|
1180
|
+
});
|
|
1181
|
+
expect(response.statusCode).toBe(400);
|
|
1182
|
+
});
|
|
1183
|
+
it("rejects sessionId with slash", async () => {
|
|
1184
|
+
instance = await createViewer({
|
|
1185
|
+
port: 0,
|
|
1186
|
+
noListen: true,
|
|
1187
|
+
traceDir: testDir,
|
|
1188
|
+
});
|
|
1189
|
+
const response = await instance.app.inject({
|
|
95
1190
|
method: "GET",
|
|
96
|
-
url: "/api/sessions/
|
|
1191
|
+
url: "/api/sessions/foo%2Fbar/timeline",
|
|
1192
|
+
});
|
|
1193
|
+
expect(response.statusCode).toBeGreaterThanOrEqual(400);
|
|
1194
|
+
});
|
|
1195
|
+
it("rejects sessionId with non-alphanumeric special chars", async () => {
|
|
1196
|
+
instance = await createViewer({
|
|
1197
|
+
port: 0,
|
|
1198
|
+
noListen: true,
|
|
1199
|
+
traceDir: testDir,
|
|
1200
|
+
});
|
|
1201
|
+
const response = await instance.app.inject({
|
|
1202
|
+
method: "GET",
|
|
1203
|
+
url: "/api/sessions/foo!bar/timeline",
|
|
1204
|
+
});
|
|
1205
|
+
expect(response.statusCode).toBe(400);
|
|
1206
|
+
});
|
|
1207
|
+
it("rejects sessionId with hyphen at edge (still valid in regex)", async () => {
|
|
1208
|
+
instance = await createViewer({
|
|
1209
|
+
port: 0,
|
|
1210
|
+
noListen: true,
|
|
1211
|
+
traceDir: testDir,
|
|
1212
|
+
});
|
|
1213
|
+
const response = await instance.app.inject({
|
|
1214
|
+
method: "GET",
|
|
1215
|
+
url: "/api/sessions/valid-id-123/timeline",
|
|
97
1216
|
});
|
|
98
1217
|
expect(response.statusCode).toBe(404);
|
|
1218
|
+
expect(response.json()).toEqual({ error: "Session not found" });
|
|
1219
|
+
});
|
|
1220
|
+
it("rejects sessionId with dot", async () => {
|
|
1221
|
+
instance = await createViewer({
|
|
1222
|
+
port: 0,
|
|
1223
|
+
noListen: true,
|
|
1224
|
+
traceDir: testDir,
|
|
1225
|
+
});
|
|
1226
|
+
const response = await instance.app.inject({
|
|
1227
|
+
method: "GET",
|
|
1228
|
+
url: "/api/sessions/foo.bar/timeline",
|
|
1229
|
+
});
|
|
1230
|
+
expect(response.statusCode).toBe(400);
|
|
1231
|
+
});
|
|
1232
|
+
});
|
|
1233
|
+
describe("validateRecordId (via 400 responses)", () => {
|
|
1234
|
+
it("rejects recordId=0", async () => {
|
|
1235
|
+
instance = await createViewer({
|
|
1236
|
+
port: 0,
|
|
1237
|
+
noListen: true,
|
|
1238
|
+
traceDir: testDir,
|
|
1239
|
+
});
|
|
1240
|
+
const response = await instance.app.inject({
|
|
1241
|
+
method: "GET",
|
|
1242
|
+
url: "/api/sessions/abc/records/0/latency",
|
|
1243
|
+
});
|
|
1244
|
+
expect(response.statusCode).toBe(400);
|
|
1245
|
+
});
|
|
1246
|
+
it("rejects negative recordId", async () => {
|
|
1247
|
+
instance = await createViewer({
|
|
1248
|
+
port: 0,
|
|
1249
|
+
noListen: true,
|
|
1250
|
+
traceDir: testDir,
|
|
1251
|
+
});
|
|
1252
|
+
const response = await instance.app.inject({
|
|
1253
|
+
method: "GET",
|
|
1254
|
+
url: "/api/sessions/abc/records/-1/latency",
|
|
1255
|
+
});
|
|
1256
|
+
expect(response.statusCode).toBeGreaterThanOrEqual(400);
|
|
1257
|
+
});
|
|
1258
|
+
it("rejects non-numeric recordId", async () => {
|
|
1259
|
+
instance = await createViewer({
|
|
1260
|
+
port: 0,
|
|
1261
|
+
noListen: true,
|
|
1262
|
+
traceDir: testDir,
|
|
1263
|
+
});
|
|
1264
|
+
const response = await instance.app.inject({
|
|
1265
|
+
method: "GET",
|
|
1266
|
+
url: "/api/sessions/abc/records/abc/latency",
|
|
1267
|
+
});
|
|
1268
|
+
expect(response.statusCode).toBe(400);
|
|
1269
|
+
});
|
|
1270
|
+
it("rejects recordId > 999999", async () => {
|
|
1271
|
+
instance = await createViewer({
|
|
1272
|
+
port: 0,
|
|
1273
|
+
noListen: true,
|
|
1274
|
+
traceDir: testDir,
|
|
1275
|
+
});
|
|
1276
|
+
const response = await instance.app.inject({
|
|
1277
|
+
method: "GET",
|
|
1278
|
+
url: "/api/sessions/abc/records/1000000/latency",
|
|
1279
|
+
});
|
|
1280
|
+
expect(response.statusCode).toBe(400);
|
|
1281
|
+
});
|
|
1282
|
+
it("rejects recordId that is purely non-numeric", async () => {
|
|
1283
|
+
instance = await createViewer({
|
|
1284
|
+
port: 0,
|
|
1285
|
+
noListen: true,
|
|
1286
|
+
traceDir: testDir,
|
|
1287
|
+
});
|
|
1288
|
+
const response = await instance.app.inject({
|
|
1289
|
+
method: "GET",
|
|
1290
|
+
url: "/api/sessions/abc/records/abc/latency",
|
|
1291
|
+
});
|
|
1292
|
+
expect(response.statusCode).toBe(400);
|
|
99
1293
|
});
|
|
100
1294
|
});
|
|
101
1295
|
});
|