@sean.holung/minicode 0.2.3 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,759 @@
1
+ import assert from "node:assert/strict";
2
+ import { test, afterEach } from "node:test";
3
+ import { createServer } from "node:http";
4
+ import { mkdirSync, writeFileSync, rmSync } from "node:fs";
5
+ import { createRequestHandler } from "../src/serve/server.js";
6
+ import { AgentBridge } from "../src/serve/agent-bridge.js";
7
+ import { createTestAgentConfig } from "./test-utils.js";
8
+ /**
9
+ * Lightweight AgentBridge subclass for testing.
10
+ * Overrides all public methods so no real config/agent/index is needed.
11
+ */
12
+ class MockBridge extends AgentBridge {
13
+ _busy = false;
14
+ turnHistory = [];
15
+ constructor() {
16
+ super(() => { }, false);
17
+ }
18
+ isBusy() {
19
+ return this._busy;
20
+ }
21
+ getConfig() {
22
+ return createTestAgentConfig("/tmp/test-workspace");
23
+ }
24
+ async runTurn(message) {
25
+ if (this._busy)
26
+ throw new Error("busy");
27
+ this._busy = true;
28
+ this.turnHistory.push(message);
29
+ // Simulate streaming by emitting events via the listener system
30
+ this.emit({ type: "streaming_chunk", content: `Echo: ${message}` });
31
+ this._busy = false;
32
+ return {
33
+ text: `Echo: ${message}`,
34
+ usage: { inputTokens: 10, outputTokens: 5 },
35
+ };
36
+ }
37
+ /** Expose the private emit for test streaming simulation. */
38
+ emit(msg) {
39
+ // Call listeners registered via addListener
40
+ for (const fn of this.listeners) {
41
+ fn(msg);
42
+ }
43
+ }
44
+ async listSess() {
45
+ return [
46
+ {
47
+ id: "sess-1",
48
+ label: "test-session",
49
+ createdAt: "2026-01-01T00:00:00.000Z",
50
+ savedAt: "2026-01-01T00:00:00.000Z",
51
+ messageCount: 3,
52
+ },
53
+ ];
54
+ }
55
+ async saveSess(label) {
56
+ return {
57
+ id: "sess-new",
58
+ label: label ?? "auto-label",
59
+ createdAt: "2026-01-01T00:00:00.000Z",
60
+ savedAt: "2026-01-01T00:00:00.000Z",
61
+ messageCount: 1,
62
+ };
63
+ }
64
+ async loadSess(label) {
65
+ if (label === "nonexistent")
66
+ return null;
67
+ return { session: {}, label };
68
+ }
69
+ setBusy(busy) {
70
+ this._busy = busy;
71
+ }
72
+ hasIndex() {
73
+ return true;
74
+ }
75
+ getSymbols() {
76
+ return [
77
+ { name: "foo", qualifiedName: "foo", kind: "function", filePath: "src/foo.ts", startLine: 1, endLine: 5, signature: "function foo(): void", exported: true },
78
+ { name: "Bar", qualifiedName: "Bar", kind: "class", filePath: "src/bar.ts", startLine: 1, endLine: 20, signature: "class Bar", exported: true },
79
+ { name: "helper", qualifiedName: "Bar.helper", kind: "method", filePath: "src/bar.ts", startLine: 10, endLine: 15, signature: "helper(): string", exported: false },
80
+ ];
81
+ }
82
+ getSymbol(name) {
83
+ const syms = this.getSymbols();
84
+ const match = syms.find((s) => s.qualifiedName === name || s.name === name);
85
+ if (!match)
86
+ return undefined;
87
+ return { ...match, dependencies: [], kind: match.kind };
88
+ }
89
+ getDependencies(symbolName) {
90
+ if (symbolName === "foo") {
91
+ return [
92
+ { name: "foo", qualifiedName: "foo", kind: "function", filePath: "src/foo.ts", signature: "function foo(): void" },
93
+ { name: "Bar", qualifiedName: "Bar", kind: "class", filePath: "src/bar.ts", signature: "class Bar" },
94
+ ];
95
+ }
96
+ return undefined;
97
+ }
98
+ getReferences(symbolName) {
99
+ if (symbolName === "Bar") {
100
+ return [{ from: "foo", kind: "calls" }];
101
+ }
102
+ return undefined;
103
+ }
104
+ getCodeMap() {
105
+ return { text: "## src/foo.ts\n- foo(): void", shownCount: 1, totalCount: 3 };
106
+ }
107
+ getGraph() {
108
+ return {
109
+ nodes: [
110
+ { id: "foo", name: "foo", kind: "function", filePath: "src/foo.ts", exported: true },
111
+ { id: "Bar", name: "Bar", kind: "class", filePath: "src/bar.ts", exported: true },
112
+ ],
113
+ edges: [{ from: "foo", to: "Bar", kind: "calls" }],
114
+ };
115
+ }
116
+ getPinnedSymbols() {
117
+ return [...this._pinned];
118
+ }
119
+ _pinned = new Set();
120
+ pinSymbol(name) {
121
+ if (name === "nonexistent")
122
+ return false;
123
+ this._pinned.add(name);
124
+ return true;
125
+ }
126
+ unpinSymbol(name) {
127
+ this._pinned.delete(name);
128
+ return true;
129
+ }
130
+ // Annotation state for testing
131
+ _annotations = new Map();
132
+ getAnnotations() {
133
+ return Object.fromEntries(this._annotations);
134
+ }
135
+ getAnnotationsForSymbol(name) {
136
+ // Resolve symbol name to qualifiedName
137
+ const sym = this.getSymbol(name);
138
+ const key = sym ? sym.qualifiedName : name;
139
+ return this._annotations.get(key) ?? [];
140
+ }
141
+ addAnnotation(name, text) {
142
+ const sym = this.getSymbol(name);
143
+ if (!sym)
144
+ return false;
145
+ const trimmed = text.slice(0, 500).trim();
146
+ if (trimmed.length === 0)
147
+ return false;
148
+ const key = sym.qualifiedName;
149
+ const existing = this._annotations.get(key) ?? [];
150
+ existing.push(trimmed);
151
+ this._annotations.set(key, existing);
152
+ return true;
153
+ }
154
+ removeAnnotation(name, index) {
155
+ const notes = this._annotations.get(name);
156
+ if (!notes || index < 0 || index >= notes.length)
157
+ return false;
158
+ notes.splice(index, 1);
159
+ if (notes.length === 0)
160
+ this._annotations.delete(name);
161
+ return true;
162
+ }
163
+ clearAnnotations(name) {
164
+ this._annotations.delete(name);
165
+ }
166
+ async explainSymbol(name, onEvent) {
167
+ const sym = this.getSymbol(name);
168
+ if (!sym)
169
+ throw new Error(`Symbol "${name}" not found`);
170
+ onEvent({ type: "streaming_chunk", content: `Explaining ${name}...` });
171
+ return `Explaining ${name}...`;
172
+ }
173
+ }
174
+ // ── Test harness ──
175
+ let activeServer;
176
+ function startTestServer(bridge) {
177
+ const handler = createRequestHandler(bridge);
178
+ const server = createServer(handler);
179
+ activeServer = server;
180
+ return new Promise((resolve) => {
181
+ server.listen(0, "127.0.0.1", () => {
182
+ const addr = server.address();
183
+ if (typeof addr === "object" && addr) {
184
+ resolve(`http://127.0.0.1:${addr.port}`);
185
+ }
186
+ });
187
+ });
188
+ }
189
+ function stopTestServer() {
190
+ return new Promise((resolve) => {
191
+ if (activeServer) {
192
+ activeServer.close(() => resolve());
193
+ activeServer = undefined;
194
+ }
195
+ else {
196
+ resolve();
197
+ }
198
+ });
199
+ }
200
+ afterEach(async () => {
201
+ await stopTestServer();
202
+ });
203
+ // ── REST API tests ──
204
+ test("GET /api/status returns status and config info", async () => {
205
+ const bridge = new MockBridge();
206
+ const base = await startTestServer(bridge);
207
+ const res = await fetch(`${base}/api/status`);
208
+ assert.equal(res.status, 200);
209
+ const body = (await res.json());
210
+ assert.equal(body.status, "ready");
211
+ assert.equal(body.workspace, "/tmp/test-workspace");
212
+ assert.equal(body.model, "test-model");
213
+ assert.equal(body.provider, "anthropic");
214
+ });
215
+ test("GET /api/status returns busy when agent is busy", async () => {
216
+ const bridge = new MockBridge();
217
+ bridge.setBusy(true);
218
+ const base = await startTestServer(bridge);
219
+ const res = await fetch(`${base}/api/status`);
220
+ const body = (await res.json());
221
+ assert.equal(body.status, "busy");
222
+ });
223
+ test("GET /api/config returns formatted config", async () => {
224
+ const bridge = new MockBridge();
225
+ const base = await startTestServer(bridge);
226
+ const res = await fetch(`${base}/api/config`);
227
+ assert.equal(res.status, 200);
228
+ const body = (await res.json());
229
+ assert.ok(body.config.includes("workspaceRoot"));
230
+ assert.ok(body.config.includes("test-model"));
231
+ });
232
+ test("GET /api/sessions returns session list", async () => {
233
+ const bridge = new MockBridge();
234
+ const base = await startTestServer(bridge);
235
+ const res = await fetch(`${base}/api/sessions`);
236
+ assert.equal(res.status, 200);
237
+ const body = (await res.json());
238
+ assert.equal(body.sessions.length, 1);
239
+ assert.equal(body.sessions[0].label, "test-session");
240
+ });
241
+ test("POST /api/sessions/save saves a session", async () => {
242
+ const bridge = new MockBridge();
243
+ const base = await startTestServer(bridge);
244
+ const res = await fetch(`${base}/api/sessions/save`, {
245
+ method: "POST",
246
+ headers: { "Content-Type": "application/json" },
247
+ body: JSON.stringify({ label: "my-save" }),
248
+ });
249
+ assert.equal(res.status, 200);
250
+ const body = (await res.json());
251
+ assert.equal(body.label, "my-save");
252
+ });
253
+ test("POST /api/sessions/load returns 404 for unknown session", async () => {
254
+ const bridge = new MockBridge();
255
+ const base = await startTestServer(bridge);
256
+ const res = await fetch(`${base}/api/sessions/load`, {
257
+ method: "POST",
258
+ headers: { "Content-Type": "application/json" },
259
+ body: JSON.stringify({ label: "nonexistent" }),
260
+ });
261
+ assert.equal(res.status, 404);
262
+ });
263
+ test("POST /api/sessions/load returns success for known session", async () => {
264
+ const bridge = new MockBridge();
265
+ const base = await startTestServer(bridge);
266
+ const res = await fetch(`${base}/api/sessions/load`, {
267
+ method: "POST",
268
+ headers: { "Content-Type": "application/json" },
269
+ body: JSON.stringify({ label: "test-session" }),
270
+ });
271
+ assert.equal(res.status, 200);
272
+ const body = (await res.json());
273
+ assert.equal(body.label, "test-session");
274
+ });
275
+ test("POST /api/chat returns agent response", async () => {
276
+ const bridge = new MockBridge();
277
+ const base = await startTestServer(bridge);
278
+ const res = await fetch(`${base}/api/chat`, {
279
+ method: "POST",
280
+ headers: { "Content-Type": "application/json" },
281
+ body: JSON.stringify({ message: "hello" }),
282
+ });
283
+ assert.equal(res.status, 200);
284
+ const body = (await res.json());
285
+ assert.equal(body.text, "Echo: hello");
286
+ assert.equal(body.usage.inputTokens, 10);
287
+ assert.equal(body.usage.outputTokens, 5);
288
+ });
289
+ test("POST /api/chat returns 400 when message is missing", async () => {
290
+ const bridge = new MockBridge();
291
+ const base = await startTestServer(bridge);
292
+ const res = await fetch(`${base}/api/chat`, {
293
+ method: "POST",
294
+ headers: { "Content-Type": "application/json" },
295
+ body: JSON.stringify({}),
296
+ });
297
+ assert.equal(res.status, 400);
298
+ });
299
+ test("POST /api/chat returns 429 when agent is busy", async () => {
300
+ const bridge = new MockBridge();
301
+ bridge.setBusy(true);
302
+ const base = await startTestServer(bridge);
303
+ const res = await fetch(`${base}/api/chat`, {
304
+ method: "POST",
305
+ headers: { "Content-Type": "application/json" },
306
+ body: JSON.stringify({ message: "hello" }),
307
+ });
308
+ assert.equal(res.status, 429);
309
+ });
310
+ // ── OpenAI-compatible API tests ──
311
+ test("GET /v1/models returns minicode-agent model", async () => {
312
+ const bridge = new MockBridge();
313
+ const base = await startTestServer(bridge);
314
+ const res = await fetch(`${base}/v1/models`);
315
+ assert.equal(res.status, 200);
316
+ const body = (await res.json());
317
+ assert.equal(body.object, "list");
318
+ assert.equal(body.data.length, 1);
319
+ assert.equal(body.data[0].id, "minicode-agent");
320
+ assert.equal(body.data[0].owned_by, "minicode");
321
+ });
322
+ test("POST /v1/chat/completions non-streaming returns OpenAI-format response", async () => {
323
+ const bridge = new MockBridge();
324
+ const base = await startTestServer(bridge);
325
+ const res = await fetch(`${base}/v1/chat/completions`, {
326
+ method: "POST",
327
+ headers: { "Content-Type": "application/json" },
328
+ body: JSON.stringify({
329
+ model: "minicode-agent",
330
+ messages: [{ role: "user", content: "list files" }],
331
+ }),
332
+ });
333
+ assert.equal(res.status, 200);
334
+ const body = (await res.json());
335
+ assert.ok(body.id.startsWith("chatcmpl-"));
336
+ assert.equal(body.object, "chat.completion");
337
+ assert.equal(body.model, "minicode-agent");
338
+ assert.equal(body.choices.length, 1);
339
+ assert.equal(body.choices[0].message.role, "assistant");
340
+ assert.equal(body.choices[0].message.content, "Echo: list files");
341
+ assert.equal(body.choices[0].finish_reason, "stop");
342
+ assert.equal(body.usage.prompt_tokens, 10);
343
+ assert.equal(body.usage.completion_tokens, 5);
344
+ assert.equal(body.usage.total_tokens, 15);
345
+ });
346
+ test("POST /v1/chat/completions returns 400 for empty messages", async () => {
347
+ const bridge = new MockBridge();
348
+ const base = await startTestServer(bridge);
349
+ const res = await fetch(`${base}/v1/chat/completions`, {
350
+ method: "POST",
351
+ headers: { "Content-Type": "application/json" },
352
+ body: JSON.stringify({ model: "minicode-agent", messages: [] }),
353
+ });
354
+ assert.equal(res.status, 400);
355
+ });
356
+ test("POST /v1/chat/completions returns 400 for no user message", async () => {
357
+ const bridge = new MockBridge();
358
+ const base = await startTestServer(bridge);
359
+ const res = await fetch(`${base}/v1/chat/completions`, {
360
+ method: "POST",
361
+ headers: { "Content-Type": "application/json" },
362
+ body: JSON.stringify({
363
+ model: "minicode-agent",
364
+ messages: [{ role: "system", content: "You are helpful" }],
365
+ }),
366
+ });
367
+ assert.equal(res.status, 400);
368
+ });
369
+ test("POST /v1/chat/completions returns 429 when busy", async () => {
370
+ const bridge = new MockBridge();
371
+ bridge.setBusy(true);
372
+ const base = await startTestServer(bridge);
373
+ const res = await fetch(`${base}/v1/chat/completions`, {
374
+ method: "POST",
375
+ headers: { "Content-Type": "application/json" },
376
+ body: JSON.stringify({
377
+ model: "minicode-agent",
378
+ messages: [{ role: "user", content: "hello" }],
379
+ }),
380
+ });
381
+ assert.equal(res.status, 429);
382
+ });
383
+ test("POST /v1/chat/completions streaming returns SSE chunks", async () => {
384
+ const bridge = new MockBridge();
385
+ const base = await startTestServer(bridge);
386
+ const res = await fetch(`${base}/v1/chat/completions`, {
387
+ method: "POST",
388
+ headers: { "Content-Type": "application/json" },
389
+ body: JSON.stringify({
390
+ model: "minicode-agent",
391
+ messages: [{ role: "user", content: "stream test" }],
392
+ stream: true,
393
+ }),
394
+ });
395
+ assert.equal(res.status, 200);
396
+ assert.equal(res.headers.get("content-type"), "text/event-stream");
397
+ const text = await res.text();
398
+ const lines = text.split("\n").filter((l) => l.startsWith("data: "));
399
+ // Should have at least: role chunk, finish chunk, [DONE]
400
+ assert.ok(lines.length >= 2, `Expected at least 2 data lines, got ${lines.length}`);
401
+ // First chunk should have role
402
+ const firstChunk = JSON.parse(lines[0].slice(6));
403
+ assert.equal(firstChunk.choices[0].delta.role, "assistant");
404
+ // Last data line before [DONE] should have finish_reason
405
+ const lastDataLine = lines.filter((l) => l !== "data: [DONE]").pop();
406
+ const lastChunk = JSON.parse(lastDataLine.slice(6));
407
+ assert.equal(lastChunk.choices[0].finish_reason, "stop");
408
+ // Should end with [DONE]
409
+ assert.ok(text.includes("data: [DONE]"));
410
+ });
411
+ // ── Static file serving tests ──
412
+ test("GET / serves index.html", async () => {
413
+ const bridge = new MockBridge();
414
+ const base = await startTestServer(bridge);
415
+ const res = await fetch(`${base}/`);
416
+ assert.equal(res.status, 200);
417
+ assert.equal(res.headers.get("content-type"), "text/html");
418
+ const html = await res.text();
419
+ assert.ok(html.includes("minicode"));
420
+ });
421
+ test("GET /style.css serves CSS file", async () => {
422
+ const bridge = new MockBridge();
423
+ const base = await startTestServer(bridge);
424
+ const res = await fetch(`${base}/style.css`);
425
+ assert.equal(res.status, 200);
426
+ assert.equal(res.headers.get("content-type"), "text/css");
427
+ });
428
+ test("GET /app.js serves JS file", async () => {
429
+ const bridge = new MockBridge();
430
+ const base = await startTestServer(bridge);
431
+ const res = await fetch(`${base}/app.js`);
432
+ assert.equal(res.status, 200);
433
+ assert.equal(res.headers.get("content-type"), "application/javascript");
434
+ });
435
+ test("GET /nonexistent returns 404", async () => {
436
+ const bridge = new MockBridge();
437
+ const base = await startTestServer(bridge);
438
+ const res = await fetch(`${base}/nonexistent.html`);
439
+ assert.equal(res.status, 404);
440
+ });
441
+ // ── CLI args tests ──
442
+ test("parseCliArgs parses serve subcommand", async () => {
443
+ const { parseCliArgs } = await import("../src/cli/args.js");
444
+ const parsed = parseCliArgs(["node", "minicode", "serve"]);
445
+ assert.equal(parsed.serve, true);
446
+ assert.equal(parsed.port, 4567);
447
+ });
448
+ test("parseCliArgs parses serve with --port", async () => {
449
+ const { parseCliArgs } = await import("../src/cli/args.js");
450
+ const parsed = parseCliArgs(["node", "minicode", "serve", "--port", "8080"]);
451
+ assert.equal(parsed.serve, true);
452
+ assert.equal(parsed.port, 8080);
453
+ });
454
+ test("parseCliArgs parses serve with --port=value", async () => {
455
+ const { parseCliArgs } = await import("../src/cli/args.js");
456
+ const parsed = parseCliArgs(["node", "minicode", "serve", "--port=9090"]);
457
+ assert.equal(parsed.serve, true);
458
+ assert.equal(parsed.port, 9090);
459
+ });
460
+ test("validateCliArgs rejects serve with --oneshot", async () => {
461
+ const { validateCliArgs, CliUsageError } = await import("../src/cli/args.js");
462
+ assert.throws(() => validateCliArgs({ verbose: false, oneshot: true, json: false, serve: true, port: 4567, task: "test" }), (err) => err instanceof CliUsageError);
463
+ });
464
+ // ── Graph / Index API tests ──
465
+ test("GET /api/symbols returns all symbols", async () => {
466
+ const bridge = new MockBridge();
467
+ const base = await startTestServer(bridge);
468
+ const res = await fetch(`${base}/api/symbols`);
469
+ assert.equal(res.status, 200);
470
+ const body = (await res.json());
471
+ assert.equal(body.symbols.length, 3);
472
+ assert.equal(body.symbols[0].name, "foo");
473
+ assert.equal(body.symbols[1].name, "Bar");
474
+ assert.equal(body.symbols[2].name, "helper");
475
+ });
476
+ test("GET /api/symbols/:name/dependencies returns dependency cone", async () => {
477
+ const bridge = new MockBridge();
478
+ const base = await startTestServer(bridge);
479
+ const res = await fetch(`${base}/api/symbols/foo/dependencies`);
480
+ assert.equal(res.status, 200);
481
+ const body = (await res.json());
482
+ assert.equal(body.symbol, "foo");
483
+ assert.equal(body.dependencies.length, 2);
484
+ });
485
+ test("GET /api/symbols/:name/dependencies returns 404 for unknown symbol", async () => {
486
+ const bridge = new MockBridge();
487
+ const base = await startTestServer(bridge);
488
+ const res = await fetch(`${base}/api/symbols/nonexistent/dependencies`);
489
+ assert.equal(res.status, 404);
490
+ });
491
+ test("GET /api/symbols/:name/references returns references", async () => {
492
+ const bridge = new MockBridge();
493
+ const base = await startTestServer(bridge);
494
+ const res = await fetch(`${base}/api/symbols/Bar/references`);
495
+ assert.equal(res.status, 200);
496
+ const body = (await res.json());
497
+ assert.equal(body.symbol, "Bar");
498
+ assert.equal(body.references.length, 1);
499
+ assert.equal(body.references[0].from, "foo");
500
+ });
501
+ test("GET /api/symbols/:name/references returns 404 for unknown symbol", async () => {
502
+ const bridge = new MockBridge();
503
+ const base = await startTestServer(bridge);
504
+ const res = await fetch(`${base}/api/symbols/nonexistent/references`);
505
+ assert.equal(res.status, 404);
506
+ });
507
+ test("GET /api/code-map returns code map", async () => {
508
+ const bridge = new MockBridge();
509
+ const base = await startTestServer(bridge);
510
+ const res = await fetch(`${base}/api/code-map`);
511
+ assert.equal(res.status, 200);
512
+ const body = (await res.json());
513
+ assert.ok(body.text.includes("foo"));
514
+ assert.equal(body.shownCount, 1);
515
+ assert.equal(body.totalCount, 3);
516
+ });
517
+ test("GET /api/graph returns nodes and edges", async () => {
518
+ const bridge = new MockBridge();
519
+ const base = await startTestServer(bridge);
520
+ const res = await fetch(`${base}/api/graph`);
521
+ assert.equal(res.status, 200);
522
+ const body = (await res.json());
523
+ assert.equal(body.nodes.length, 2);
524
+ assert.equal(body.edges.length, 1);
525
+ assert.equal(body.edges[0].from, "foo");
526
+ assert.equal(body.edges[0].to, "Bar");
527
+ assert.equal(body.edges[0].kind, "calls");
528
+ });
529
+ test("GET /api/focus returns pinned symbols", async () => {
530
+ const bridge = new MockBridge();
531
+ const base = await startTestServer(bridge);
532
+ const res = await fetch(`${base}/api/focus`);
533
+ assert.equal(res.status, 200);
534
+ const body = (await res.json());
535
+ assert.deepEqual(body.pinned, []);
536
+ });
537
+ test("POST /api/focus pins and unpins symbols", async () => {
538
+ const bridge = new MockBridge();
539
+ const base = await startTestServer(bridge);
540
+ // Pin
541
+ const pinRes = await fetch(`${base}/api/focus`, {
542
+ method: "POST",
543
+ headers: { "Content-Type": "application/json" },
544
+ body: JSON.stringify({ action: "pin", symbol: "foo" }),
545
+ });
546
+ assert.equal(pinRes.status, 200);
547
+ const pinBody = (await pinRes.json());
548
+ assert.ok(pinBody.pinned.includes("foo"));
549
+ // Unpin
550
+ const unpinRes = await fetch(`${base}/api/focus`, {
551
+ method: "POST",
552
+ headers: { "Content-Type": "application/json" },
553
+ body: JSON.stringify({ action: "unpin", symbol: "foo" }),
554
+ });
555
+ assert.equal(unpinRes.status, 200);
556
+ const unpinBody = (await unpinRes.json());
557
+ assert.ok(!unpinBody.pinned.includes("foo"));
558
+ });
559
+ test("POST /api/focus returns 404 for unknown symbol", async () => {
560
+ const bridge = new MockBridge();
561
+ const base = await startTestServer(bridge);
562
+ const res = await fetch(`${base}/api/focus`, {
563
+ method: "POST",
564
+ headers: { "Content-Type": "application/json" },
565
+ body: JSON.stringify({ action: "pin", symbol: "nonexistent" }),
566
+ });
567
+ assert.equal(res.status, 404);
568
+ });
569
+ test("POST /api/focus returns 400 for invalid action", async () => {
570
+ const bridge = new MockBridge();
571
+ const base = await startTestServer(bridge);
572
+ const res = await fetch(`${base}/api/focus`, {
573
+ method: "POST",
574
+ headers: { "Content-Type": "application/json" },
575
+ body: JSON.stringify({ action: "toggle", symbol: "foo" }),
576
+ });
577
+ assert.equal(res.status, 400);
578
+ });
579
+ // ── Symbol source endpoint tests ──
580
+ test("GET /api/symbols/:name/source returns source code for known symbol", async () => {
581
+ const bridge = new MockBridge();
582
+ const base = await startTestServer(bridge);
583
+ // Create a temp file matching the mock symbol's filePath
584
+ const wsRoot = "/tmp/test-workspace";
585
+ mkdirSync(`${wsRoot}/src`, { recursive: true });
586
+ writeFileSync(`${wsRoot}/src/foo.ts`, "line1\nfunction foo(): void {\n return;\n}\nline5\n");
587
+ try {
588
+ const res = await fetch(`${base}/api/symbols/foo/source`);
589
+ assert.equal(res.status, 200);
590
+ const body = (await res.json());
591
+ assert.equal(body.symbol, "foo");
592
+ assert.equal(body.filePath, "src/foo.ts");
593
+ assert.equal(body.startLine, 1);
594
+ assert.equal(body.endLine, 5);
595
+ assert.ok(body.source.includes("line1"));
596
+ }
597
+ finally {
598
+ rmSync(wsRoot, { recursive: true, force: true });
599
+ }
600
+ });
601
+ test("GET /api/symbols/:name/source returns 404 for unknown symbol", async () => {
602
+ const bridge = new MockBridge();
603
+ const base = await startTestServer(bridge);
604
+ const res = await fetch(`${base}/api/symbols/nonexistent/source`);
605
+ assert.equal(res.status, 404);
606
+ const body = (await res.json());
607
+ assert.ok(body.error.includes("nonexistent"));
608
+ });
609
+ test("GET /api/symbols/:name/source returns 500 when file is missing", async () => {
610
+ const bridge = new MockBridge();
611
+ const base = await startTestServer(bridge);
612
+ // Don't create the file — let it fail with a read error
613
+ const res = await fetch(`${base}/api/symbols/foo/source`);
614
+ assert.equal(res.status, 500);
615
+ const body = (await res.json());
616
+ assert.ok(body.error.includes("src/foo.ts"));
617
+ });
618
+ // ── Annotations API tests ──
619
+ test("GET /api/annotations returns empty annotations initially", async () => {
620
+ const bridge = new MockBridge();
621
+ const base = await startTestServer(bridge);
622
+ const res = await fetch(`${base}/api/annotations`);
623
+ assert.equal(res.status, 200);
624
+ const body = (await res.json());
625
+ assert.deepEqual(body.annotations, {});
626
+ });
627
+ test("POST /api/symbols/:name/annotations adds annotation", async () => {
628
+ const bridge = new MockBridge();
629
+ const base = await startTestServer(bridge);
630
+ const res = await fetch(`${base}/api/symbols/foo/annotations`, {
631
+ method: "POST",
632
+ headers: { "Content-Type": "application/json" },
633
+ body: JSON.stringify({ text: "don't modify, stable API" }),
634
+ });
635
+ assert.equal(res.status, 200);
636
+ const body = (await res.json());
637
+ assert.equal(body.symbol, "foo");
638
+ assert.equal(body.annotations.length, 1);
639
+ assert.equal(body.annotations[0], "don't modify, stable API");
640
+ });
641
+ test("GET /api/symbols/:name/annotations returns annotations for symbol", async () => {
642
+ const bridge = new MockBridge();
643
+ const base = await startTestServer(bridge);
644
+ // Add one first
645
+ await fetch(`${base}/api/symbols/foo/annotations`, {
646
+ method: "POST",
647
+ headers: { "Content-Type": "application/json" },
648
+ body: JSON.stringify({ text: "note 1" }),
649
+ });
650
+ const res = await fetch(`${base}/api/symbols/foo/annotations`);
651
+ assert.equal(res.status, 200);
652
+ const body = (await res.json());
653
+ assert.equal(body.annotations.length, 1);
654
+ assert.equal(body.annotations[0], "note 1");
655
+ });
656
+ test("POST /api/symbols/:name/annotations returns 404 for unknown symbol", async () => {
657
+ const bridge = new MockBridge();
658
+ const base = await startTestServer(bridge);
659
+ const res = await fetch(`${base}/api/symbols/nonexistent/annotations`, {
660
+ method: "POST",
661
+ headers: { "Content-Type": "application/json" },
662
+ body: JSON.stringify({ text: "hello" }),
663
+ });
664
+ assert.equal(res.status, 404);
665
+ });
666
+ test("POST /api/symbols/:name/annotations returns 400 for missing text", async () => {
667
+ const bridge = new MockBridge();
668
+ const base = await startTestServer(bridge);
669
+ const res = await fetch(`${base}/api/symbols/foo/annotations`, {
670
+ method: "POST",
671
+ headers: { "Content-Type": "application/json" },
672
+ body: JSON.stringify({}),
673
+ });
674
+ assert.equal(res.status, 400);
675
+ });
676
+ test("DELETE /api/symbols/:name/annotations/:index removes annotation", async () => {
677
+ const bridge = new MockBridge();
678
+ const base = await startTestServer(bridge);
679
+ // Add two annotations
680
+ await fetch(`${base}/api/symbols/foo/annotations`, {
681
+ method: "POST",
682
+ headers: { "Content-Type": "application/json" },
683
+ body: JSON.stringify({ text: "first" }),
684
+ });
685
+ await fetch(`${base}/api/symbols/foo/annotations`, {
686
+ method: "POST",
687
+ headers: { "Content-Type": "application/json" },
688
+ body: JSON.stringify({ text: "second" }),
689
+ });
690
+ // Remove first
691
+ const res = await fetch(`${base}/api/symbols/foo/annotations/0`, {
692
+ method: "DELETE",
693
+ });
694
+ assert.equal(res.status, 200);
695
+ const body = (await res.json());
696
+ assert.equal(body.annotations.length, 1);
697
+ assert.equal(body.annotations[0], "second");
698
+ });
699
+ test("DELETE /api/symbols/:name/annotations/:index returns 404 for invalid index", async () => {
700
+ const bridge = new MockBridge();
701
+ const base = await startTestServer(bridge);
702
+ const res = await fetch(`${base}/api/symbols/foo/annotations/99`, {
703
+ method: "DELETE",
704
+ });
705
+ assert.equal(res.status, 404);
706
+ });
707
+ test("DELETE /api/symbols/:name/annotations clears all annotations", async () => {
708
+ const bridge = new MockBridge();
709
+ const base = await startTestServer(bridge);
710
+ // Add annotations
711
+ await fetch(`${base}/api/symbols/foo/annotations`, {
712
+ method: "POST",
713
+ headers: { "Content-Type": "application/json" },
714
+ body: JSON.stringify({ text: "note" }),
715
+ });
716
+ // Clear all
717
+ const res = await fetch(`${base}/api/symbols/foo/annotations`, {
718
+ method: "DELETE",
719
+ });
720
+ assert.equal(res.status, 200);
721
+ const body = (await res.json());
722
+ assert.deepEqual(body.annotations, []);
723
+ });
724
+ test("GET /api/annotations returns all annotations after adding", async () => {
725
+ const bridge = new MockBridge();
726
+ const base = await startTestServer(bridge);
727
+ await fetch(`${base}/api/symbols/foo/annotations`, {
728
+ method: "POST",
729
+ headers: { "Content-Type": "application/json" },
730
+ body: JSON.stringify({ text: "foo note" }),
731
+ });
732
+ await fetch(`${base}/api/symbols/Bar/annotations`, {
733
+ method: "POST",
734
+ headers: { "Content-Type": "application/json" },
735
+ body: JSON.stringify({ text: "bar note" }),
736
+ });
737
+ const res = await fetch(`${base}/api/annotations`);
738
+ assert.equal(res.status, 200);
739
+ const body = (await res.json());
740
+ assert.ok(Object.keys(body.annotations).length >= 2);
741
+ });
742
+ test("GET /api/symbols/:name/explain returns SSE stream", async () => {
743
+ const bridge = new MockBridge();
744
+ const base = await startTestServer(bridge);
745
+ const res = await fetch(`${base}/api/symbols/foo/explain`);
746
+ assert.equal(res.status, 200);
747
+ assert.equal(res.headers.get("content-type"), "text/event-stream");
748
+ const text = await res.text();
749
+ assert.ok(text.includes("data: "));
750
+ assert.ok(text.includes("[DONE]"));
751
+ });
752
+ test("GET /api/symbols/:name/explain returns error for unknown symbol", async () => {
753
+ const bridge = new MockBridge();
754
+ const base = await startTestServer(bridge);
755
+ const res = await fetch(`${base}/api/symbols/nonexistent/explain`);
756
+ assert.equal(res.status, 200); // SSE always starts 200
757
+ const text = await res.text();
758
+ assert.ok(text.includes("error"));
759
+ });