@opencode-trace/plugin 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,8 +1,10 @@
1
- import { describe, test, expect, beforeEach, afterEach } from "vitest";
1
+ import { describe, test, expect, beforeEach, afterEach, vi } from "vitest";
2
2
  import { TracePlugin } from "./plugin-instance.js";
3
- import { mkdtempSync, rmSync, readFileSync, existsSync } from "node:fs";
3
+ import { mkdtempSync, rmSync, readFileSync, existsSync, writeFileSync, readdirSync, statSync, } from "node:fs";
4
4
  import { tmpdir, homedir } from "node:os";
5
5
  import { join } from "node:path";
6
+ import { logger } from "@opencode-trace/core";
7
+ import { promises as fsp } from "node:fs";
6
8
  async function waitForFile(filePath, timeoutMs = 5000) {
7
9
  const startTime = Date.now();
8
10
  while (true) {
@@ -14,13 +16,12 @@ async function waitForFile(filePath, timeoutMs = 5000) {
14
16
  return;
15
17
  }
16
18
  }
17
- catch {
18
- }
19
+ catch { }
19
20
  }
20
21
  if (Date.now() - startTime > timeoutMs) {
21
22
  throw new Error(`Timeout waiting for valid file ${filePath} after ${timeoutMs}ms`);
22
23
  }
23
- await new Promise(r => setTimeout(r, 10));
24
+ await new Promise((r) => setTimeout(r, 10));
24
25
  }
25
26
  }
26
27
  describe("TracePlugin", () => {
@@ -28,16 +29,15 @@ describe("TracePlugin", () => {
28
29
  let plugin;
29
30
  beforeEach(() => {
30
31
  tempDir = mkdtempSync(join(tmpdir(), "plugin-test-"));
31
- plugin = new TracePlugin(tempDir, tempDir);
32
+ plugin = new TracePlugin({ globalDir: tempDir, localDir: tempDir });
32
33
  });
33
34
  afterEach(() => {
34
35
  plugin.uninstallInterceptor();
35
36
  rmSync(tempDir, { recursive: true, force: true });
36
37
  });
37
- test("constructor initializes write and state queues", () => {
38
+ test("constructor initializes write queue", () => {
38
39
  expect(plugin).toBeDefined();
39
40
  expect(plugin["writeQueue"]).toBeDefined();
40
- expect(plugin["stateQueue"]).toBeDefined();
41
41
  });
42
42
  test("installInterceptor installs traced fetch", () => {
43
43
  const originalFetch = globalThis.fetch;
@@ -78,7 +78,7 @@ describe("TracePlugin", () => {
78
78
  const mockFetch = async () => {
79
79
  return new Response(JSON.stringify({ result: "ok" }), {
80
80
  status: 200,
81
- headers: { "content-type": "application/json" }
81
+ headers: { "content-type": "application/json" },
82
82
  });
83
83
  };
84
84
  globalThis.fetch = mockFetch;
@@ -88,9 +88,9 @@ describe("TracePlugin", () => {
88
88
  method: "POST",
89
89
  headers: {
90
90
  "x-opencode-session": sessionId,
91
- "content-type": "application/json"
91
+ "content-type": "application/json",
92
92
  },
93
- body: JSON.stringify({ test: true })
93
+ body: JSON.stringify({ test: true }),
94
94
  });
95
95
  const response = await plugin.tracedFetch(request);
96
96
  const filePath = join(tempDir, sessionId, "1.json");
@@ -107,14 +107,14 @@ describe("TracePlugin", () => {
107
107
  Connection to 192.168.1.100:8080 failed
108
108
  Server running on 127.0.0.1:3000`;
109
109
  const sanitized = sanitizeStackTrace(stack);
110
- expect(sanitized).toContain('[HOME]');
111
- expect(sanitized).toContain('[IP]');
112
- expect(sanitized).toContain(':[PORT]');
110
+ expect(sanitized).toContain("[HOME]");
111
+ expect(sanitized).toContain("[IP]");
112
+ expect(sanitized).toContain(":[PORT]");
113
113
  expect(sanitized).not.toContain(userHome);
114
- expect(sanitized).not.toContain('192.168.1.100');
115
- expect(sanitized).not.toContain('127.0.0.1');
116
- expect(sanitized).not.toContain(':8080');
117
- expect(sanitized).not.toContain(':3000');
114
+ expect(sanitized).not.toContain("192.168.1.100");
115
+ expect(sanitized).not.toContain("127.0.0.1");
116
+ expect(sanitized).not.toContain(":8080");
117
+ expect(sanitized).not.toContain(":3000");
118
118
  });
119
119
  test("sanitizeStackTrace redacts ports in Windows paths", () => {
120
120
  const sanitizeStackTrace = plugin["sanitizeStackTrace"];
@@ -123,13 +123,853 @@ Server running on 127.0.0.1:3000`;
123
123
  Connection to 10.0.0.1:8080 failed
124
124
  Listening on 0.0.0.0:3000`;
125
125
  const sanitized = sanitizeStackTrace(windowsStack);
126
- expect(sanitized).toContain('[HOME]');
127
- expect(sanitized).toContain('[IP]');
128
- expect(sanitized).toContain(':[PORT]');
129
- expect(sanitized).not.toContain('10.0.0.1');
130
- expect(sanitized).not.toContain('0.0.0.0');
131
- expect(sanitized).not.toContain(':8080');
132
- expect(sanitized).not.toContain(':3000');
126
+ expect(sanitized).toContain("[HOME]");
127
+ expect(sanitized).toContain("[IP]");
128
+ expect(sanitized).toContain(":[PORT]");
129
+ expect(sanitized).not.toContain("10.0.0.1");
130
+ expect(sanitized).not.toContain("0.0.0.0");
131
+ expect(sanitized).not.toContain(":8080");
132
+ expect(sanitized).not.toContain(":3000");
133
+ });
134
+ });
135
+ describe("TracePlugin - constructor & init", () => {
136
+ let globalDir;
137
+ let localDir;
138
+ beforeEach(() => {
139
+ globalDir = mkdtempSync(join(tmpdir(), "plugin-global-"));
140
+ localDir = mkdtempSync(join(tmpdir(), "plugin-local-"));
141
+ });
142
+ afterEach(() => {
143
+ rmSync(globalDir, { recursive: true, force: true });
144
+ rmSync(localDir, { recursive: true, force: true });
145
+ });
146
+ test("throws TypeError when localDir is missing", () => {
147
+ expect(() => new TracePlugin({ globalDir })).toThrow(TypeError);
148
+ expect(() => new TracePlugin({})).toThrow(/localDir is required/);
149
+ });
150
+ test("defaults globalDir to ~/.opencode-trace when omitted", () => {
151
+ const p = new TracePlugin({ localDir });
152
+ const status = p.getScopeStatus();
153
+ expect(status.globalDir).toBe(join(homedir(), ".opencode-trace"));
154
+ expect(status.localDir).toBe(localDir);
155
+ });
156
+ test("initStateManager creates both config managers and config files", async () => {
157
+ const p = new TracePlugin({ globalDir, localDir });
158
+ expect(p.getStateManager()).toBeNull();
159
+ expect(p.getGlobalConfigManager()).toBeNull();
160
+ expect(p.getLocalConfigManager()).toBeNull();
161
+ await p.initStateManager();
162
+ expect(p.getStateManager()).not.toBeNull();
163
+ expect(p.getGlobalConfigManager()).not.toBeNull();
164
+ expect(p.getLocalConfigManager()).not.toBeNull();
165
+ expect(p.getStateManager()).toBe(p.getGlobalConfigManager());
166
+ expect(existsSync(join(globalDir, "config.json"))).toBe(true);
167
+ expect(existsSync(join(localDir, "config.json"))).toBe(true);
168
+ });
169
+ });
170
+ describe("TracePlugin - shouldRecord (scope resolution)", () => {
171
+ let globalDir;
172
+ let localDir;
173
+ let plugin;
174
+ beforeEach(async () => {
175
+ globalDir = mkdtempSync(join(tmpdir(), "plugin-sr-g-"));
176
+ localDir = mkdtempSync(join(tmpdir(), "plugin-sr-l-"));
177
+ plugin = new TracePlugin({ globalDir, localDir });
178
+ await plugin.initStateManager();
179
+ });
180
+ afterEach(() => {
181
+ plugin.uninstallInterceptor();
182
+ rmSync(globalDir, { recursive: true, force: true });
183
+ rmSync(localDir, { recursive: true, force: true });
184
+ });
185
+ test("returns true when state managers not initialized (defensive default)", () => {
186
+ const fresh = new TracePlugin({ globalDir, localDir });
187
+ expect(fresh.shouldRecord("s1")).toBe(true);
188
+ expect(fresh.shouldRecord()).toBe(true);
189
+ });
190
+ test("global enabled → true regardless of local/session", () => {
191
+ const gcm = plugin.getGlobalConfigManager();
192
+ const lcm = plugin.getLocalConfigManager();
193
+ gcm.setGlobalState("global_trace_enabled", "true");
194
+ lcm.setGlobalState("global_trace_enabled", "false");
195
+ gcm.setSessionEnabled("s1", false);
196
+ expect(plugin.shouldRecord("s1")).toBe(true);
197
+ expect(plugin.shouldRecord()).toBe(true);
198
+ });
199
+ test("global disabled + local enabled → true", () => {
200
+ const gcm = plugin.getGlobalConfigManager();
201
+ const lcm = plugin.getLocalConfigManager();
202
+ gcm.setGlobalState("global_trace_enabled", "false");
203
+ lcm.setGlobalState("global_trace_enabled", "true");
204
+ gcm.setSessionEnabled("s1", false);
205
+ expect(plugin.shouldRecord("s1")).toBe(true);
206
+ expect(plugin.shouldRecord()).toBe(true);
207
+ });
208
+ test("global disabled + local disabled + session enabled → true", () => {
209
+ const gcm = plugin.getGlobalConfigManager();
210
+ const lcm = plugin.getLocalConfigManager();
211
+ gcm.setGlobalState("global_trace_enabled", "false");
212
+ lcm.setGlobalState("global_trace_enabled", "false");
213
+ gcm.setSessionEnabled("s1", true);
214
+ expect(plugin.shouldRecord("s1")).toBe(true);
215
+ });
216
+ test("global disabled + local disabled + session disabled → false", () => {
217
+ const gcm = plugin.getGlobalConfigManager();
218
+ const lcm = plugin.getLocalConfigManager();
219
+ gcm.setGlobalState("global_trace_enabled", "false");
220
+ lcm.setGlobalState("global_trace_enabled", "false");
221
+ gcm.setSessionEnabled("s1", false);
222
+ expect(plugin.shouldRecord("s1")).toBe(false);
223
+ });
224
+ test("global disabled + local disabled + no sessionId → false", () => {
225
+ const gcm = plugin.getGlobalConfigManager();
226
+ const lcm = plugin.getLocalConfigManager();
227
+ gcm.setGlobalState("global_trace_enabled", "false");
228
+ lcm.setGlobalState("global_trace_enabled", "false");
229
+ expect(plugin.shouldRecord(undefined)).toBe(false);
230
+ expect(plugin.shouldRecord()).toBe(false);
231
+ });
232
+ test("global disabled + local disabled + unknown session → true (default trace_enabled=true)", () => {
233
+ const gcm = plugin.getGlobalConfigManager();
234
+ const lcm = plugin.getLocalConfigManager();
235
+ gcm.setGlobalState("global_trace_enabled", "false");
236
+ lcm.setGlobalState("global_trace_enabled", "false");
237
+ expect(plugin.shouldRecord("never-seen")).toBe(true);
238
+ });
239
+ });
240
+ describe("TracePlugin - resolveTraceDir (storage preference)", () => {
241
+ let globalDir;
242
+ let localDir;
243
+ let plugin;
244
+ beforeEach(async () => {
245
+ globalDir = mkdtempSync(join(tmpdir(), "plugin-rd-g-"));
246
+ localDir = mkdtempSync(join(tmpdir(), "plugin-rd-l-"));
247
+ plugin = new TracePlugin({ globalDir, localDir });
248
+ await plugin.initStateManager();
249
+ });
250
+ afterEach(() => {
251
+ plugin.uninstallInterceptor();
252
+ rmSync(globalDir, { recursive: true, force: true });
253
+ rmSync(localDir, { recursive: true, force: true });
254
+ });
255
+ test("returns globalDir when managers not initialized", () => {
256
+ const fresh = new TracePlugin({ globalDir, localDir });
257
+ expect(fresh.resolveTraceDir("s1")).toBe(globalDir);
258
+ expect(fresh.resolveTraceDir()).toBe(globalDir);
259
+ });
260
+ test("no session pref + global pref 'global' → globalDir", () => {
261
+ plugin.getGlobalConfigManager().setStoragePreference("global");
262
+ expect(plugin.resolveTraceDir("s1")).toBe(globalDir);
263
+ expect(plugin.resolveTraceDir()).toBe(globalDir);
264
+ });
265
+ test("no session pref + global pref 'local' → localDir", () => {
266
+ plugin.getGlobalConfigManager().setStoragePreference("local");
267
+ expect(plugin.resolveTraceDir("s1")).toBe(localDir);
268
+ expect(plugin.resolveTraceDir()).toBe(localDir);
269
+ });
270
+ test("session pref 'global' overrides global pref 'local' → globalDir", () => {
271
+ const gcm = plugin.getGlobalConfigManager();
272
+ gcm.setStoragePreference("local");
273
+ gcm.setSessionStoragePreference("s1", "global");
274
+ expect(plugin.resolveTraceDir("s1")).toBe(globalDir);
275
+ expect(plugin.resolveTraceDir("other")).toBe(localDir);
276
+ });
277
+ test("session pref 'local' overrides global pref 'global' → localDir", () => {
278
+ const gcm = plugin.getGlobalConfigManager();
279
+ gcm.setStoragePreference("global");
280
+ gcm.setSessionStoragePreference("s1", "local");
281
+ expect(plugin.resolveTraceDir("s1")).toBe(localDir);
282
+ expect(plugin.resolveTraceDir("other")).toBe(globalDir);
283
+ });
284
+ });
285
+ describe("TracePlugin - getScopeStatus", () => {
286
+ let globalDir;
287
+ let localDir;
288
+ beforeEach(() => {
289
+ globalDir = mkdtempSync(join(tmpdir(), "plugin-ss-g-"));
290
+ localDir = mkdtempSync(join(tmpdir(), "plugin-ss-l-"));
291
+ });
292
+ afterEach(() => {
293
+ rmSync(globalDir, { recursive: true, force: true });
294
+ rmSync(localDir, { recursive: true, force: true });
295
+ });
296
+ test("returns defaults when state manager not initialized", () => {
297
+ const p = new TracePlugin({ globalDir, localDir });
298
+ const status = p.getScopeStatus("s1");
299
+ expect(status.globalEnabled).toBe(false);
300
+ expect(status.localEnabled).toBe(false);
301
+ expect(status.sessionEnabled).toBeNull();
302
+ expect(status.effectiveEnabled).toBe(true);
303
+ expect(status.storageLocation).toBe("global");
304
+ expect(status.globalDir).toBe(globalDir);
305
+ expect(status.localDir).toBe(localDir);
306
+ });
307
+ test("reports sessionEnabled=null when no sessionId provided", async () => {
308
+ const p = new TracePlugin({ globalDir, localDir });
309
+ await p.initStateManager();
310
+ const status = p.getScopeStatus();
311
+ expect(status.sessionEnabled).toBeNull();
312
+ });
313
+ test("reports full status with all scopes engaged", async () => {
314
+ const p = new TracePlugin({ globalDir, localDir });
315
+ await p.initStateManager();
316
+ const gcm = p.getGlobalConfigManager();
317
+ const lcm = p.getLocalConfigManager();
318
+ gcm.setGlobalState("global_trace_enabled", "true");
319
+ lcm.setGlobalState("global_trace_enabled", "false");
320
+ gcm.setStoragePreference("local");
321
+ gcm.setSessionEnabled("s1", true);
322
+ const status = p.getScopeStatus("s1");
323
+ expect(status.globalEnabled).toBe(true);
324
+ expect(status.localEnabled).toBe(false);
325
+ expect(status.sessionEnabled).toBe(true);
326
+ expect(status.effectiveEnabled).toBe(true);
327
+ expect(status.storageLocation).toBe("local");
328
+ });
329
+ test("reports storageLocation='global' when resolveTraceDir picks global", async () => {
330
+ const p = new TracePlugin({ globalDir, localDir });
331
+ await p.initStateManager();
332
+ p.getGlobalConfigManager().setStoragePreference("global");
333
+ const status = p.getScopeStatus("s1");
334
+ expect(status.storageLocation).toBe("global");
335
+ });
336
+ });
337
+ describe("TracePlugin - session metadata operations via ConfigManager", () => {
338
+ let globalDir;
339
+ let localDir;
340
+ let plugin;
341
+ beforeEach(async () => {
342
+ globalDir = mkdtempSync(join(tmpdir(), "plugin-meta-g-"));
343
+ localDir = mkdtempSync(join(tmpdir(), "plugin-meta-l-"));
344
+ plugin = new TracePlugin({ globalDir, localDir });
345
+ await plugin.initStateManager();
346
+ });
347
+ afterEach(() => {
348
+ plugin.uninstallInterceptor();
349
+ rmSync(globalDir, { recursive: true, force: true });
350
+ rmSync(localDir, { recursive: true, force: true });
351
+ });
352
+ test("setSessionEnabled / getSessionEnabled write to session metadata file", () => {
353
+ const gcm = plugin.getGlobalConfigManager();
354
+ const sessionId = "meta-session";
355
+ expect(gcm.getSessionEnabled(sessionId)).toBe(true);
356
+ gcm.setSessionEnabled(sessionId, false);
357
+ expect(gcm.getSessionEnabled(sessionId)).toBe(false);
358
+ const metaPath = join(globalDir, sessionId, "metadata.json");
359
+ const meta = JSON.parse(readFileSync(metaPath, "utf-8"));
360
+ expect(meta.trace_enabled).toBe(false);
361
+ gcm.setSessionEnabled(sessionId, true);
362
+ expect(gcm.getSessionEnabled(sessionId)).toBe(true);
363
+ const meta2 = JSON.parse(readFileSync(metaPath, "utf-8"));
364
+ expect(meta2.trace_enabled).toBe(true);
365
+ });
366
+ test("setSessionStoragePreference / getSessionStoragePreference round-trip", () => {
367
+ const gcm = plugin.getGlobalConfigManager();
368
+ const sessionId = "storage-session";
369
+ expect(gcm.getSessionStoragePreference(sessionId)).toBeNull();
370
+ gcm.setSessionStoragePreference(sessionId, "local");
371
+ expect(gcm.getSessionStoragePreference(sessionId)).toBe("local");
372
+ gcm.setSessionStoragePreference(sessionId, "global");
373
+ expect(gcm.getSessionStoragePreference(sessionId)).toBe("global");
374
+ const metaPath = join(globalDir, sessionId, "metadata.json");
375
+ const meta = JSON.parse(readFileSync(metaPath, "utf-8"));
376
+ expect(meta.storage_preference).toBe("global");
377
+ });
378
+ test("setStoragePreference / getStoragePreference write to global config", () => {
379
+ const gcm = plugin.getGlobalConfigManager();
380
+ expect(gcm.getStoragePreference()).toBe("global");
381
+ gcm.setStoragePreference("local");
382
+ expect(gcm.getStoragePreference()).toBe("local");
383
+ const config = JSON.parse(readFileSync(join(globalDir, "config.json"), "utf-8"));
384
+ expect(config.storage_preference).toBe("local");
385
+ });
386
+ test("addSubSession links parent to child and dedups duplicates", () => {
387
+ const gcm = plugin.getGlobalConfigManager();
388
+ gcm.startSession("parent-session");
389
+ gcm.startSession("child-1");
390
+ gcm.startSession("child-2");
391
+ gcm.addSubSession("parent-session", "child-1");
392
+ gcm.addSubSession("parent-session", "child-2");
393
+ gcm.addSubSession("parent-session", "child-1");
394
+ const metaPath = join(globalDir, "parent-session", "metadata.json");
395
+ const meta = JSON.parse(readFileSync(metaPath, "utf-8"));
396
+ expect(meta.subSessions).toEqual(["child-1", "child-2"]);
397
+ });
398
+ test("updateSessionMetadata sets parentID on child for sub-session linking", () => {
399
+ const gcm = plugin.getGlobalConfigManager();
400
+ gcm.startSession("parent-id");
401
+ gcm.startSession("child-id");
402
+ gcm.updateSessionMetadata("child-id", { parentID: "parent-id" });
403
+ const metaPath = join(globalDir, "child-id", "metadata.json");
404
+ const meta = JSON.parse(readFileSync(metaPath, "utf-8"));
405
+ expect(meta.parentID).toBe("parent-id");
406
+ });
407
+ });
408
+ describe("TracePlugin - config corruption recovery", () => {
409
+ test("initStateManager falls back to defaults when config.json is invalid JSON", async () => {
410
+ const globalDir = mkdtempSync(join(tmpdir(), "plugin-corrupt-g-"));
411
+ const localDir = mkdtempSync(join(tmpdir(), "plugin-corrupt-l-"));
412
+ try {
413
+ writeFileSync(join(globalDir, "config.json"), "{ this is not valid json", "utf-8");
414
+ writeFileSync(join(localDir, "config.json"), "{{{", "utf-8");
415
+ const errorSpy = vi.spyOn(logger, "error").mockImplementation(((..._args) => logger));
416
+ const p = new TracePlugin({ globalDir, localDir });
417
+ await p.initStateManager();
418
+ const gcm = p.getGlobalConfigManager();
419
+ expect(gcm.getGlobalState("global_trace_enabled")).toBe("false");
420
+ expect(gcm.getGlobalState("plugin_enabled")).toBe("true");
421
+ expect(gcm.getStoragePreference()).toBe("global");
422
+ expect(errorSpy).toHaveBeenCalled();
423
+ const calls = errorSpy.mock.calls.map((c) => String(c[0]));
424
+ expect(calls.some((m) => /config\.json/i.test(m))).toBe(true);
425
+ errorSpy.mockRestore();
426
+ }
427
+ finally {
428
+ rmSync(globalDir, { recursive: true, force: true });
429
+ rmSync(localDir, { recursive: true, force: true });
430
+ }
431
+ });
432
+ });
433
+ describe("TracePlugin - tracedFetch edge cases", () => {
434
+ let tempDir;
435
+ let plugin;
436
+ let savedFetch;
437
+ beforeEach(() => {
438
+ tempDir = mkdtempSync(join(tmpdir(), "plugin-edge-"));
439
+ plugin = new TracePlugin({ globalDir: tempDir, localDir: tempDir });
440
+ savedFetch = globalThis.fetch;
441
+ });
442
+ afterEach(async () => {
443
+ plugin.uninstallInterceptor();
444
+ globalThis.fetch = savedFetch;
445
+ await plugin.flush();
446
+ rmSync(tempDir, { recursive: true, force: true });
447
+ });
448
+ test("delegates without recording when no session header is present", async () => {
449
+ let invoked = false;
450
+ globalThis.fetch = async () => {
451
+ invoked = true;
452
+ return new Response(JSON.stringify({ ok: true }), { status: 200 });
453
+ };
454
+ plugin.installInterceptor();
455
+ const res = await plugin.tracedFetch("https://example.com");
456
+ expect(invoked).toBe(true);
457
+ expect(res.status).toBe(200);
458
+ await plugin.flush();
459
+ const sessionDirs = readdirSync(tempDir).filter((e) => {
460
+ try {
461
+ return statSync(join(tempDir, e)).isDirectory();
462
+ }
463
+ catch {
464
+ return false;
465
+ }
466
+ });
467
+ expect(sessionDirs.length).toBe(0);
468
+ });
469
+ test("delegates without recording when shouldRecord returns false", async () => {
470
+ await plugin.initStateManager();
471
+ const gcm = plugin.getGlobalConfigManager();
472
+ gcm.setGlobalState("global_trace_enabled", "false");
473
+ gcm.setSessionEnabled("blocked", false);
474
+ let invoked = false;
475
+ globalThis.fetch = async () => {
476
+ invoked = true;
477
+ return new Response("ok", { status: 200 });
478
+ };
479
+ plugin.installInterceptor();
480
+ const req = new Request("https://example.com", {
481
+ headers: { "x-opencode-session": "blocked" },
482
+ });
483
+ const res = await plugin.tracedFetch(req);
484
+ expect(invoked).toBe(true);
485
+ expect(res.status).toBe(200);
486
+ await plugin.flush();
487
+ const sessionDir = join(tempDir, "blocked");
488
+ if (existsSync(sessionDir)) {
489
+ const recordFiles = readdirSync(sessionDir).filter((f) => /^\d+\.json$/.test(f));
490
+ expect(recordFiles.length).toBe(0);
491
+ }
492
+ });
493
+ test("records error and rethrows when delegate fetch throws Error", async () => {
494
+ const fetchError = new Error("Network down");
495
+ globalThis.fetch = async () => {
496
+ throw fetchError;
497
+ };
498
+ plugin.installInterceptor();
499
+ const sessionId = "err-session";
500
+ const req = new Request("https://example.com", {
501
+ method: "POST",
502
+ headers: {
503
+ "x-opencode-session": sessionId,
504
+ "content-type": "application/json",
505
+ },
506
+ body: JSON.stringify({ q: 1 }),
507
+ });
508
+ await expect(plugin.tracedFetch(req)).rejects.toThrow("Network down");
509
+ const filePath = join(tempDir, sessionId, "1.json");
510
+ await waitForFile(filePath, 5000);
511
+ const content = JSON.parse(readFileSync(filePath, "utf-8"));
512
+ expect(content.response).toBeNull();
513
+ expect(content.error).toBeTruthy();
514
+ expect(content.error.message).toBe("Network down");
515
+ });
516
+ test("records error and rethrows when delegate throws non-Error value", async () => {
517
+ globalThis.fetch = async () => {
518
+ throw "boom-string";
519
+ };
520
+ plugin.installInterceptor();
521
+ const sessionId = "str-err-session";
522
+ const req = new Request("https://example.com", {
523
+ method: "POST",
524
+ headers: { "x-opencode-session": sessionId },
525
+ body: "{}",
526
+ });
527
+ await expect(plugin.tracedFetch(req)).rejects.toBe("boom-string");
528
+ const filePath = join(tempDir, sessionId, "1.json");
529
+ await waitForFile(filePath, 5000);
530
+ const content = JSON.parse(readFileSync(filePath, "utf-8"));
531
+ expect(content.error).toBeTruthy();
532
+ expect(content.error.message).toBe("boom-string");
533
+ expect(content.error.stack).toBeUndefined();
534
+ });
535
+ test("wraps streaming response and captures latency metadata", async () => {
536
+ globalThis.fetch = async () => {
537
+ const encoder = new TextEncoder();
538
+ const stream = new ReadableStream({
539
+ start(controller) {
540
+ controller.enqueue(encoder.encode("data: chunk1\n\n"));
541
+ controller.enqueue(encoder.encode("data: chunk2\n\n"));
542
+ controller.close();
543
+ },
544
+ });
545
+ return new Response(stream, {
546
+ status: 200,
547
+ headers: { "content-type": "text/event-stream" },
548
+ });
549
+ };
550
+ plugin.installInterceptor();
551
+ const sessionId = "stream-session";
552
+ const req = new Request("https://example.com", {
553
+ method: "POST",
554
+ headers: {
555
+ "x-opencode-session": sessionId,
556
+ "content-type": "application/json",
557
+ },
558
+ body: JSON.stringify({ stream: true, model: "test" }),
559
+ });
560
+ const res = await plugin.tracedFetch(req);
561
+ expect(res.status).toBe(200);
562
+ await res.text();
563
+ const filePath = join(tempDir, sessionId, "1.json");
564
+ await waitForFile(filePath, 5000);
565
+ const content = JSON.parse(readFileSync(filePath, "utf-8"));
566
+ expect(content.requestSentAt).toBeTypeOf("number");
567
+ expect(content.firstTokenAt).toBeTypeOf("number");
568
+ expect(content.lastTokenAt).toBeTypeOf("number");
569
+ expect(content.firstTokenAt).toBeGreaterThanOrEqual(content.requestSentAt);
570
+ expect(content.lastTokenAt).toBeGreaterThanOrEqual(content.firstTokenAt);
571
+ });
572
+ test("captures request body when not valid JSON (raw string)", async () => {
573
+ globalThis.fetch = async () => new Response("plain", {
574
+ status: 200,
575
+ headers: { "content-type": "text/plain" },
576
+ });
577
+ plugin.installInterceptor();
578
+ const sessionId = "raw-session";
579
+ const req = new Request("https://example.com", {
580
+ method: "POST",
581
+ headers: {
582
+ "x-opencode-session": sessionId,
583
+ "content-type": "text/plain",
584
+ },
585
+ body: "hello world",
586
+ });
587
+ const res = await plugin.tracedFetch(req);
588
+ expect(res.status).toBe(200);
589
+ const filePath = join(tempDir, sessionId, "1.json");
590
+ await waitForFile(filePath, 5000);
591
+ const content = JSON.parse(readFileSync(filePath, "utf-8"));
592
+ expect(content.request.body).toBe("hello world");
593
+ });
594
+ test("classifyPurpose returns '' for body with non-empty tools array", () => {
595
+ const classify = plugin["classifyPurpose"].bind(plugin);
596
+ expect(classify({ tools: [{ name: "tool1" }] })).toBe("");
597
+ expect(classify({ tools: [] })).toBe("[meta]");
598
+ expect(classify({})).toBe("[meta]");
599
+ expect(classify(null)).toBe("[meta]");
600
+ expect(classify("text")).toBe("[meta]");
601
+ expect(classify([1, 2, 3])).toBe("[meta]");
602
+ });
603
+ });
604
+ describe("TracePlugin - buildTimelineEntry provider & token extraction", () => {
605
+ let tempDir;
606
+ let plugin;
607
+ let savedFetch;
608
+ beforeEach(() => {
609
+ tempDir = mkdtempSync(join(tmpdir(), "plugin-tl-"));
610
+ plugin = new TracePlugin({ globalDir: tempDir, localDir: tempDir });
611
+ savedFetch = globalThis.fetch;
612
+ });
613
+ afterEach(async () => {
614
+ plugin.uninstallInterceptor();
615
+ globalThis.fetch = savedFetch;
616
+ await plugin.flush();
617
+ rmSync(tempDir, { recursive: true, force: true });
618
+ });
619
+ async function waitForNdjsonLine(path, timeoutMs = 5000) {
620
+ const start = Date.now();
621
+ while (Date.now() - start < timeoutMs) {
622
+ if (existsSync(path)) {
623
+ const raw = readFileSync(path, "utf-8");
624
+ const firstLine = raw.split("\n").find((l) => l.trim());
625
+ if (firstLine) {
626
+ try {
627
+ return JSON.parse(firstLine);
628
+ }
629
+ catch { }
630
+ }
631
+ }
632
+ await new Promise((r) => setTimeout(r, 10));
633
+ }
634
+ throw new Error(`Timeout waiting for ndjson line in ${path}`);
635
+ }
636
+ test("extracts openai provider with prompt/completion tokens", async () => {
637
+ globalThis.fetch = async () => new Response(JSON.stringify({
638
+ model: "gpt-4",
639
+ usage: { prompt_tokens: 120, completion_tokens: 60 },
640
+ }), { status: 200, headers: { "content-type": "application/json" } });
641
+ plugin.installInterceptor();
642
+ const req = new Request("https://api.openai.com/v1/chat/completions", {
643
+ method: "POST",
644
+ headers: { "x-opencode-session": "oai", "content-type": "application/json" },
645
+ body: "{}",
646
+ });
647
+ await plugin.tracedFetch(req);
648
+ const entry = await waitForNdjsonLine(join(tempDir, "oai", "timeline.ndjson"));
649
+ expect(entry.provider).toBe("openai");
650
+ expect(entry.model).toBe("gpt-4");
651
+ expect(entry.inputTokens).toBe(120);
652
+ expect(entry.outputTokens).toBe(60);
653
+ });
654
+ test("extracts anthropic provider with input/output tokens", async () => {
655
+ globalThis.fetch = async () => new Response(JSON.stringify({
656
+ model: "claude-3-opus",
657
+ usage: { input_tokens: 200, output_tokens: 80 },
658
+ }), { status: 200, headers: { "content-type": "application/json" } });
659
+ plugin.installInterceptor();
660
+ const req = new Request("https://api.anthropic.com/v1/messages", {
661
+ method: "POST",
662
+ headers: { "x-opencode-session": "ant", "content-type": "application/json" },
663
+ body: "{}",
664
+ });
665
+ await plugin.tracedFetch(req);
666
+ const entry = await waitForNdjsonLine(join(tempDir, "ant", "timeline.ndjson"));
667
+ expect(entry.provider).toBe("anthropic");
668
+ expect(entry.model).toBe("claude-3-opus");
669
+ expect(entry.inputTokens).toBe(200);
670
+ expect(entry.outputTokens).toBe(80);
671
+ });
672
+ test("provider is null for unknown URL, tokens null when usage absent", async () => {
673
+ globalThis.fetch = async () => new Response(JSON.stringify({ ok: true }), {
674
+ status: 200,
675
+ headers: { "content-type": "application/json" },
676
+ });
677
+ plugin.installInterceptor();
678
+ const req = new Request("https://example.com/api", {
679
+ method: "POST",
680
+ headers: { "x-opencode-session": "unk", "content-type": "application/json" },
681
+ body: "{}",
682
+ });
683
+ await plugin.tracedFetch(req);
684
+ const entry = await waitForNdjsonLine(join(tempDir, "unk", "timeline.ndjson"));
685
+ expect(entry.provider).toBeNull();
686
+ expect(entry.model).toBeNull();
687
+ expect(entry.inputTokens).toBeNull();
688
+ expect(entry.outputTokens).toBeNull();
689
+ });
690
+ });
691
+ describe("TracePlugin - flush, wrap, getInterceptor, getSessionId", () => {
692
+ let tempDir;
693
+ let plugin;
694
+ let savedFetch;
695
+ beforeEach(() => {
696
+ tempDir = mkdtempSync(join(tmpdir(), "plugin-misc-"));
697
+ plugin = new TracePlugin({ globalDir: tempDir, localDir: tempDir });
698
+ savedFetch = globalThis.fetch;
699
+ });
700
+ afterEach(async () => {
701
+ plugin.uninstallInterceptor();
702
+ globalThis.fetch = savedFetch;
703
+ await plugin.flush();
704
+ rmSync(tempDir, { recursive: true, force: true });
705
+ });
706
+ test("flush awaits underlying writeQueue.flush()", async () => {
707
+ const spy = vi
708
+ .spyOn(plugin["writeQueue"], "flush")
709
+ .mockResolvedValue(undefined);
710
+ await plugin.flush();
711
+ expect(spy).toHaveBeenCalled();
712
+ spy.mockRestore();
713
+ });
714
+ test("wrap returns a function that uses the provided fetch as origFetch", async () => {
715
+ let providedCalled = 0;
716
+ const provided = (async () => {
717
+ providedCalled++;
718
+ return new Response("from-provided", { status: 201 });
719
+ });
720
+ const wrapped = plugin.wrap(provided);
721
+ const res = await wrapped("https://example.com");
722
+ expect(providedCalled).toBe(1);
723
+ expect(res.status).toBe(201);
724
+ });
725
+ test("getInterceptor returns a function that uses origFetch captured at construction", async () => {
726
+ let origCalled = 0;
727
+ const fakeOrig = (async () => {
728
+ origCalled++;
729
+ return new Response("from-orig", { status: 202 });
730
+ });
731
+ globalThis.fetch = fakeOrig;
732
+ const p = new TracePlugin({ globalDir: tempDir, localDir: tempDir });
733
+ globalThis.fetch = savedFetch;
734
+ const interceptor = p.getInterceptor();
735
+ const res = await interceptor("https://example.com");
736
+ expect(origCalled).toBe(1);
737
+ expect(res.status).toBe(202);
738
+ });
739
+ test("getSessionId reads from x-session-affinity header", async () => {
740
+ globalThis.fetch = async () => new Response("ok", { status: 200, headers: { "content-type": "text/plain" } });
741
+ plugin.installInterceptor();
742
+ const req = new Request("https://example.com", {
743
+ method: "POST",
744
+ headers: { "x-session-affinity": "affinity-session" },
745
+ body: "{}",
746
+ });
747
+ await plugin.tracedFetch(req);
748
+ const filePath = join(tempDir, "affinity-session", "1.json");
749
+ await waitForFile(filePath, 5000);
750
+ expect(existsSync(filePath)).toBe(true);
751
+ });
752
+ test("getSessionId reads from session_id header", async () => {
753
+ globalThis.fetch = async () => new Response("ok", { status: 200, headers: { "content-type": "text/plain" } });
754
+ plugin.installInterceptor();
755
+ const req = new Request("https://example.com", {
756
+ method: "POST",
757
+ headers: { session_id: "fallback-session" },
758
+ body: "{}",
759
+ });
760
+ await plugin.tracedFetch(req);
761
+ const filePath = join(tempDir, "fallback-session", "1.json");
762
+ await waitForFile(filePath, 5000);
763
+ expect(existsSync(filePath)).toBe(true);
764
+ });
765
+ test("sequence numbers increment per session", async () => {
766
+ globalThis.fetch = async () => new Response("ok", { status: 200, headers: { "content-type": "text/plain" } });
767
+ plugin.installInterceptor();
768
+ const sessionId = "seq-session";
769
+ for (let i = 0; i < 3; i++) {
770
+ const req = new Request(`https://example.com/${i}`, {
771
+ method: "POST",
772
+ headers: { "x-opencode-session": sessionId },
773
+ body: "{}",
774
+ });
775
+ await plugin.tracedFetch(req);
776
+ }
777
+ await waitForFile(join(tempDir, sessionId, "3.json"), 5000);
778
+ expect(existsSync(join(tempDir, sessionId, "1.json"))).toBe(true);
779
+ expect(existsSync(join(tempDir, sessionId, "2.json"))).toBe(true);
780
+ expect(existsSync(join(tempDir, sessionId, "3.json"))).toBe(true);
781
+ });
782
+ });
783
+ describe("TracePlugin - coverage gap tests", () => {
784
+ let tempDir;
785
+ let plugin;
786
+ let savedFetch;
787
+ beforeEach(() => {
788
+ tempDir = mkdtempSync(join(tmpdir(), "plugin-gap-"));
789
+ plugin = new TracePlugin({ globalDir: tempDir, localDir: tempDir });
790
+ savedFetch = globalThis.fetch;
791
+ });
792
+ afterEach(async () => {
793
+ plugin.uninstallInterceptor();
794
+ globalThis.fetch = savedFetch;
795
+ await plugin.flush();
796
+ rmSync(tempDir, { recursive: true, force: true });
797
+ });
798
+ test("recordResponse captures error with sanitized stack when res.clone() throws", async () => {
799
+ const cloneSpy = vi.spyOn(Response.prototype, "clone").mockImplementation(() => {
800
+ throw new Error("response body unusable");
801
+ });
802
+ globalThis.fetch = async () => new Response(JSON.stringify({ ok: true }), {
803
+ status: 200,
804
+ headers: { "content-type": "application/json" },
805
+ });
806
+ plugin.installInterceptor();
807
+ const sessionId = "resp-clone-err";
808
+ const req = new Request("https://example.com", {
809
+ method: "POST",
810
+ headers: {
811
+ "x-opencode-session": sessionId,
812
+ "content-type": "application/json",
813
+ },
814
+ body: JSON.stringify({ test: true }),
815
+ });
816
+ const res = await plugin.tracedFetch(req);
817
+ expect(res.status).toBe(200);
818
+ await plugin.flush();
819
+ const filePath = join(tempDir, sessionId, "1.json");
820
+ await waitForFile(filePath, 5000);
821
+ const content = JSON.parse(readFileSync(filePath, "utf-8"));
822
+ expect(content.response).toBeNull();
823
+ expect(content.error).toBeTruthy();
824
+ expect(content.error.message).toBe("response body unusable");
825
+ expect(content.error.stack).toBeTruthy();
826
+ cloneSpy.mockRestore();
827
+ });
828
+ test("recordResponse captures non-Error throw from res.clone()", async () => {
829
+ const cloneSpy = vi.spyOn(Response.prototype, "clone").mockImplementation(() => {
830
+ throw "raw-string-error";
831
+ });
832
+ globalThis.fetch = async () => new Response("ok", {
833
+ status: 200,
834
+ headers: { "content-type": "text/plain" },
835
+ });
836
+ plugin.installInterceptor();
837
+ const sessionId = "resp-non-err";
838
+ const req = new Request("https://example.com", {
839
+ method: "POST",
840
+ headers: { "x-opencode-session": sessionId },
841
+ body: "{}",
842
+ });
843
+ const res = await plugin.tracedFetch(req);
844
+ expect(res.status).toBe(200);
845
+ await plugin.flush();
846
+ const filePath = join(tempDir, sessionId, "1.json");
847
+ await waitForFile(filePath, 5000);
848
+ const content = JSON.parse(readFileSync(filePath, "utf-8"));
849
+ expect(content.response).toBeNull();
850
+ expect(content.error.message).toBe("raw-string-error");
851
+ expect(content.error.stack).toBeUndefined();
852
+ cloneSpy.mockRestore();
853
+ });
854
+ test("wrapStreamResponse logs error and continues stream when SSE chunk write fails", async () => {
855
+ const mockHandle = {
856
+ write: vi.fn().mockRejectedValue(new Error("disk full")),
857
+ close: vi.fn().mockResolvedValue(undefined),
858
+ };
859
+ const _realOpen = fsp.open;
860
+ const openSpy = vi.spyOn(fsp, "open").mockImplementation(async (path, flags, mode) => {
861
+ if (String(path).includes(".sse")) {
862
+ return mockHandle;
863
+ }
864
+ return _realOpen(path, flags, mode);
865
+ });
866
+ const errorSpy = vi.spyOn(logger, "error").mockImplementation(((..._args) => logger));
867
+ globalThis.fetch = async () => {
868
+ const encoder = new TextEncoder();
869
+ const stream = new ReadableStream({
870
+ start(controller) {
871
+ controller.enqueue(encoder.encode("data: chunk1\n\n"));
872
+ controller.enqueue(encoder.encode("data: chunk2\n\n"));
873
+ controller.close();
874
+ },
875
+ });
876
+ return new Response(stream, {
877
+ status: 200,
878
+ headers: { "content-type": "text/event-stream" },
879
+ });
880
+ };
881
+ plugin.installInterceptor();
882
+ const sessionId = "sse-write-err";
883
+ const req = new Request("https://example.com", {
884
+ method: "POST",
885
+ headers: {
886
+ "x-opencode-session": sessionId,
887
+ "content-type": "application/json",
888
+ },
889
+ body: JSON.stringify({ stream: true, model: "test" }),
890
+ });
891
+ const res = await plugin.tracedFetch(req);
892
+ expect(res.status).toBe(200);
893
+ const text = await res.text();
894
+ expect(text).toContain("data: chunk1");
895
+ expect(text).toContain("data: chunk2");
896
+ await plugin.flush();
897
+ expect(errorSpy.mock.calls.some((c) => /Failed to write SSE chunk/i.test(String(c[0])))).toBe(true);
898
+ openSpy.mockRestore();
899
+ errorSpy.mockRestore();
900
+ });
901
+ test("wrapStreamResponse logs error when closing SSE file fails", async () => {
902
+ const mockHandle = {
903
+ write: vi.fn().mockResolvedValue(undefined),
904
+ close: vi.fn().mockRejectedValue(new Error("close failed")),
905
+ };
906
+ const _realOpen = fsp.open;
907
+ const openSpy = vi.spyOn(fsp, "open").mockImplementation(async (path, flags, mode) => {
908
+ if (String(path).includes(".sse")) {
909
+ return mockHandle;
910
+ }
911
+ return _realOpen(path, flags, mode);
912
+ });
913
+ const errorSpy = vi.spyOn(logger, "error").mockImplementation(((..._args) => logger));
914
+ globalThis.fetch = async () => {
915
+ const encoder = new TextEncoder();
916
+ const stream = new ReadableStream({
917
+ start(controller) {
918
+ controller.enqueue(encoder.encode("data: chunk1\n\n"));
919
+ controller.close();
920
+ },
921
+ });
922
+ return new Response(stream, {
923
+ status: 200,
924
+ headers: { "content-type": "text/event-stream" },
925
+ });
926
+ };
927
+ plugin.installInterceptor();
928
+ const sessionId = "sse-finalize-err";
929
+ const req = new Request("https://example.com", {
930
+ method: "POST",
931
+ headers: {
932
+ "x-opencode-session": sessionId,
933
+ "content-type": "application/json",
934
+ },
935
+ body: JSON.stringify({ stream: true, model: "test" }),
936
+ });
937
+ const res = await plugin.tracedFetch(req);
938
+ expect(res.status).toBe(200);
939
+ await res.text();
940
+ await plugin.flush();
941
+ expect(errorSpy.mock.calls.some((c) => /Failed to finalize SSE file/i.test(String(c[0])))).toBe(true);
942
+ openSpy.mockRestore();
943
+ errorSpy.mockRestore();
944
+ });
945
+ test("captureRequestMeta falls back to empty body when clone().text() rejects", async () => {
946
+ const textSpy = vi.spyOn(Request.prototype, "text").mockRejectedValue(new Error("body stream locked"));
947
+ const errorSpy = vi.spyOn(logger, "error").mockImplementation(((..._args) => logger));
948
+ globalThis.fetch = async () => new Response(JSON.stringify({ ok: true }), {
949
+ status: 200,
950
+ headers: { "content-type": "application/json" },
951
+ });
952
+ plugin.installInterceptor();
953
+ const sessionId = "clone-err-session";
954
+ const req = new Request("https://example.com", {
955
+ method: "POST",
956
+ headers: {
957
+ "x-opencode-session": sessionId,
958
+ "content-type": "application/json",
959
+ },
960
+ body: JSON.stringify({ test: true }),
961
+ });
962
+ const res = await plugin.tracedFetch(req);
963
+ expect(res.status).toBe(200);
964
+ await plugin.flush();
965
+ const filePath = join(tempDir, sessionId, "1.json");
966
+ await waitForFile(filePath, 5000);
967
+ const content = JSON.parse(readFileSync(filePath, "utf-8"));
968
+ expect(content.request.body).toBeNull();
969
+ expect(content.purpose).toBe("[meta]");
970
+ expect(errorSpy.mock.calls.some((c) => /Failed to clone request body/i.test(String(c[0])))).toBe(true);
971
+ textSpy.mockRestore();
972
+ errorSpy.mockRestore();
133
973
  });
134
974
  });
135
975
  //# sourceMappingURL=plugin-instance.test.js.map