@opencode-trace/viewer 0.0.4 → 0.0.6

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.
@@ -1,101 +1,1409 @@
1
1
  import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
- import Fastify from "fastify";
3
- import cors from "@fastify/cors";
4
- import multipart from "@fastify/multipart";
5
- import { store, parse } from "@opencode-trace/core";
6
- function buildTestApp() {
7
- const app = Fastify({ logger: false });
8
- app.register(cors, { origin: "*" });
9
- app.register(multipart);
10
- app.get("/", async (_req, reply) => {
11
- reply.type("text/html; charset=utf-8").send("<html></html>");
12
- });
13
- app.get("/api/sessions", async (_req, reply) => {
14
- return store.listSessions();
15
- });
16
- app.get("/api/sessions/tree", async (_req, reply) => {
17
- return store.listSessionsTree();
18
- });
19
- app.get("/api/sessions/:sessionId/timeline", async (req, reply) => {
20
- const { sessionId } = req.params;
21
- const records = store.getSessionRecords(sessionId);
22
- const parsedRecords = records.map((rec) => {
23
- const parsed = parse.detectAndParse(rec);
24
- const provider = parse.detectProvider(rec.request.url, rec.request.body);
25
- let requestMsgs = parsed.msgs;
26
- if (provider === "openai-chat") {
27
- requestMsgs = parse.openaiChatParser.parseRequest(rec.request.body).msgs;
28
- }
29
- else if (provider === "openai-responses") {
30
- requestMsgs = parse.openaiResponsesParser.parseRequest(rec.request.body).msgs;
31
- }
32
- else if (provider === "anthropic") {
33
- requestMsgs = parse.anthropicParser.parseRequest(rec.request.body).msgs;
34
- }
35
- return { id: rec.id, requestAt: rec.requestAt, requestMsgs, parsed };
36
- }).filter((c) => c.parsed.provider !== "unknown" || c.parsed.msgs.length > 0);
37
- const timeline = { messages: [], recordMeta: [] };
38
- return { ...timeline, recordMeta: parsedRecords.map((r) => ({ id: r.id, model: r.parsed.model, provider: r.parsed.provider })) };
39
- });
40
- app.get("/api/sessions/:sessionId/records/:recordId/latency", async (req, reply) => {
41
- const { sessionId, recordId } = req.params;
42
- const rid = parseInt(recordId, 10);
43
- const rec = store.getRecord(sessionId, rid);
44
- if (!rec) {
45
- reply.code(404);
46
- return { error: "Record not found" };
47
- }
48
- const latency = parse.extractLatency(rec);
49
- return latency ?? { error: "No latency data available" };
50
- });
51
- return app;
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
- describe("Server API", () => {
54
- let server = null;
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 (server) {
60
- await server.close();
61
- server = null;
258
+ if (instance) {
259
+ await instance.close();
260
+ instance = null;
62
261
  }
262
+ if (existsSync(testDir)) {
263
+ rmSync(testDir, { recursive: true, force: true });
264
+ }
265
+ });
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
+ });
63
777
  });
64
- describe("Latency endpoint", () => {
65
- it("should return latency info for stream requests", async () => {
66
- const mockRecord = {
67
- id: 1,
68
- purpose: "",
69
- requestAt: "2026-04-29T00:00:00.000Z",
70
- responseAt: "2026-04-29T00:00:01.000Z",
71
- request: { method: "POST", url: "https://example.com", headers: {}, body: null },
72
- response: null,
73
- error: null,
74
- requestSentAt: 1234567.89,
75
- firstTokenAt: 1234570.12,
76
- lastTokenAt: 1234590.34,
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.spyOn(store, "getRecord").mockReturnValue(mockRecord);
79
- const app = buildTestApp();
80
- await app.ready();
81
- const response = await app.inject({
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({
82
798
  method: "GET",
83
- url: "/api/sessions/test/records/1/latency",
799
+ url: "/api/sessions/abc",
84
800
  });
85
801
  expect(response.statusCode).toBe(200);
86
802
  const data = response.json();
87
- expect(data.ttft).toBeDefined();
88
- expect(data.totalDuration).toBeDefined();
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);
89
870
  });
90
- it("should return 404 for non-existent record", async () => {
91
- vi.spyOn(store, "getRecord").mockReturnValue(null);
92
- const app = buildTestApp();
93
- await app.ready();
94
- const response = await app.inject({
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({
95
880
  method: "GET",
96
- url: "/api/sessions/test/records/999/latency",
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",
97
961
  });
98
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"] },
1108
+ });
1109
+ expect(response.statusCode).toBe(200);
1110
+ const data = response.json();
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);
1170
+ });
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({
1190
+ method: "GET",
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",
1216
+ });
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);
1293
+ });
1294
+ });
1295
+ describe("findSessionTraceDir dual-dir resolution", () => {
1296
+ it("finds session in local dir when only local has metadata", async () => {
1297
+ const localDir = join(testDir, "local-trace");
1298
+ const globalDir = join(testDir, "global-trace");
1299
+ vi.mocked(store.readSessionMetadata)
1300
+ .mockImplementation(((sessionId, dir) => {
1301
+ if (dir === localDir && sessionId === "local-only-session") {
1302
+ return { title: "Local Session" };
1303
+ }
1304
+ return null;
1305
+ }));
1306
+ instance = await createViewer({
1307
+ port: 0,
1308
+ noListen: true,
1309
+ globalDir,
1310
+ localDir,
1311
+ });
1312
+ const response = await instance.app.inject({
1313
+ method: "GET",
1314
+ url: "/api/sessions/local-only-session/timeline",
1315
+ });
1316
+ expect(response.statusCode).toBe(200);
1317
+ });
1318
+ it("finds session in global dir when only global has metadata", async () => {
1319
+ const localDir = join(testDir, "local-trace");
1320
+ const globalDir = join(testDir, "global-trace");
1321
+ vi.mocked(store.readSessionMetadata)
1322
+ .mockImplementation(((sessionId, dir) => {
1323
+ if (dir === globalDir && sessionId === "global-only-session") {
1324
+ return { title: "Global Session" };
1325
+ }
1326
+ return null;
1327
+ }));
1328
+ instance = await createViewer({
1329
+ port: 0,
1330
+ noListen: true,
1331
+ globalDir,
1332
+ localDir,
1333
+ });
1334
+ const response = await instance.app.inject({
1335
+ method: "GET",
1336
+ url: "/api/sessions/global-only-session/timeline",
1337
+ });
1338
+ expect(response.statusCode).toBe(200);
1339
+ });
1340
+ it("returns 404 when session exists in neither dir", async () => {
1341
+ const localDir = join(testDir, "local-trace");
1342
+ const globalDir = join(testDir, "global-trace");
1343
+ vi.mocked(store.readSessionMetadata).mockReturnValue(null);
1344
+ vi.mocked(store.getSessionRecords).mockReturnValue([]);
1345
+ instance = await createViewer({
1346
+ port: 0,
1347
+ noListen: true,
1348
+ globalDir,
1349
+ localDir,
1350
+ });
1351
+ const response = await instance.app.inject({
1352
+ method: "GET",
1353
+ url: "/api/sessions/nonexistent-session/timeline",
1354
+ });
1355
+ expect(response.statusCode).toBe(404);
1356
+ expect(response.json()).toEqual({ error: "Session not found" });
1357
+ });
1358
+ it("prefers local dir over global when session exists in both", async () => {
1359
+ const localDir = join(testDir, "local-trace");
1360
+ const globalDir = join(testDir, "global-trace");
1361
+ vi.mocked(store.readSessionMetadata)
1362
+ .mockImplementation(((sessionId, dir) => {
1363
+ if (sessionId === "both-dir-session") {
1364
+ return { title: dir === localDir ? "Local" : "Global" };
1365
+ }
1366
+ return null;
1367
+ }));
1368
+ vi.mocked(store.readTimelineIndex).mockReturnValue([]);
1369
+ vi.mocked(store.getSessionRecords).mockReturnValue([]);
1370
+ instance = await createViewer({
1371
+ port: 0,
1372
+ noListen: true,
1373
+ globalDir,
1374
+ localDir,
1375
+ });
1376
+ const response = await instance.app.inject({
1377
+ method: "GET",
1378
+ url: "/api/sessions/both-dir-session",
1379
+ });
1380
+ expect(response.statusCode).toBe(200);
1381
+ const calls = vi.mocked(store.getSessionRecords).mock.calls;
1382
+ const usedDir = calls.length > 0 ? calls[calls.length - 1][1]?.traceDir : null;
1383
+ expect(usedDir).toBe(localDir);
1384
+ });
1385
+ it("finds session in local dir via records when metadata is null", async () => {
1386
+ const localDir = join(testDir, "local-trace");
1387
+ const globalDir = join(testDir, "global-trace");
1388
+ vi.mocked(store.readSessionMetadata).mockReturnValue(null);
1389
+ vi.mocked(store.getSessionRecords)
1390
+ .mockImplementation(((sessionId, opts) => {
1391
+ if (opts?.traceDir === localDir && sessionId === "local-records-only") {
1392
+ return [mockRecord];
1393
+ }
1394
+ return [];
1395
+ }));
1396
+ instance = await createViewer({
1397
+ port: 0,
1398
+ noListen: true,
1399
+ globalDir,
1400
+ localDir,
1401
+ });
1402
+ const response = await instance.app.inject({
1403
+ method: "GET",
1404
+ url: "/api/sessions/local-records-only",
1405
+ });
1406
+ expect(response.statusCode).toBe(200);
99
1407
  });
100
1408
  });
101
1409
  });