@aeriondyseti/vector-memory-mcp 2.3.0 → 2.4.4

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,699 +0,0 @@
1
- #!/usr/bin/env bun
2
-
3
- /**
4
- * Smoke test script for vector-memory-mcp 2.0
5
- *
6
- * Spawns a real server process and exercises the full HTTP API surface:
7
- * - Server startup & lockfile discovery
8
- * - Memory lifecycle (store/search/get/delete)
9
- * - Waypoint lifecycle (set/get via MCP + HTTP)
10
- * - Conversation history indexing
11
- * - Memory usefulness voting
12
- * - Lockfile cleanup on shutdown
13
- * - Migration detection & subcommand
14
- *
15
- * Usage: bun run scripts/smoke-test.ts
16
- * Exit code: 0 on success, 1 on any failure
17
- */
18
-
19
- import { spawn, type Subprocess } from "bun";
20
- import {
21
- mkdtempSync,
22
- rmSync,
23
- mkdirSync,
24
- writeFileSync,
25
- existsSync,
26
- readFileSync,
27
- } from "fs";
28
- import { join } from "path";
29
- import { tmpdir } from "os";
30
-
31
- // ── Helpers ─────────────────────────────────────────────────────────
32
-
33
- const SERVER_PATH = join(import.meta.dir, "../server/index.ts");
34
-
35
- let passed = 0;
36
- let failed = 0;
37
- const failures: string[] = [];
38
-
39
- function assert(condition: boolean, description: string): void {
40
- if (condition) {
41
- passed++;
42
- console.log(` ✅ ${description}`);
43
- } else {
44
- failed++;
45
- failures.push(description);
46
- console.log(` ❌ ${description}`);
47
- }
48
- }
49
-
50
- function assertContains(text: string, substring: string, description: string): void {
51
- assert(text.includes(substring), description);
52
- }
53
-
54
- function assertNotContains(text: string, substring: string, description: string): void {
55
- assert(!text.includes(substring), description);
56
- }
57
-
58
- interface JsonRpcResponse {
59
- jsonrpc: "2.0";
60
- id: number;
61
- result?: { content: { type: string; text: string }[] };
62
- error?: { code: number; message: string };
63
- }
64
-
65
- async function httpGet(baseUrl: string, path: string): Promise<Response> {
66
- return fetch(`${baseUrl}${path}`);
67
- }
68
-
69
- async function httpPost(
70
- baseUrl: string,
71
- path: string,
72
- body: Record<string, unknown>
73
- ): Promise<Response> {
74
- return fetch(`${baseUrl}${path}`, {
75
- method: "POST",
76
- headers: { "Content-Type": "application/json" },
77
- body: JSON.stringify(body),
78
- });
79
- }
80
-
81
- async function httpDelete(baseUrl: string, path: string): Promise<Response> {
82
- return fetch(`${baseUrl}${path}`, { method: "DELETE" });
83
- }
84
-
85
- /**
86
- * Initialize an MCP session over HTTP and return the session ID.
87
- */
88
- async function initMcpSession(baseUrl: string): Promise<string> {
89
- const res = await fetch(`${baseUrl}/mcp`, {
90
- method: "POST",
91
- headers: { "Content-Type": "application/json" },
92
- body: JSON.stringify({
93
- jsonrpc: "2.0",
94
- id: 0,
95
- method: "initialize",
96
- params: {
97
- protocolVersion: "2024-11-05",
98
- capabilities: {},
99
- clientInfo: { name: "smoke-test", version: "1.0" },
100
- },
101
- }),
102
- });
103
-
104
- const sessionId = res.headers.get("mcp-session-id");
105
- if (!sessionId) {
106
- throw new Error("No mcp-session-id header in initialize response");
107
- }
108
- return sessionId;
109
- }
110
-
111
- let mcpRequestId = 100;
112
-
113
- /**
114
- * Call an MCP tool via HTTP JSON-RPC and return the text result.
115
- */
116
- async function mcpCall(
117
- baseUrl: string,
118
- sessionId: string,
119
- toolName: string,
120
- args: Record<string, unknown>
121
- ): Promise<string> {
122
- const id = mcpRequestId++;
123
- const res = await fetch(`${baseUrl}/mcp`, {
124
- method: "POST",
125
- headers: {
126
- "Content-Type": "application/json",
127
- "mcp-session-id": sessionId,
128
- },
129
- body: JSON.stringify({
130
- jsonrpc: "2.0",
131
- id,
132
- method: "tools/call",
133
- params: { name: toolName, arguments: args },
134
- }),
135
- });
136
-
137
- const response = (await res.json()) as JsonRpcResponse;
138
-
139
- if (response.error) {
140
- return `ERROR: ${response.error.message}`;
141
- }
142
-
143
- return response.result?.content[0]?.text ?? "";
144
- }
145
-
146
- function extractMemoryId(text: string): string {
147
- const match = text.match(/ID: ([a-f0-9-]+)/i);
148
- if (!match) throw new Error(`Could not extract ID from: ${text}`);
149
- return match[1];
150
- }
151
-
152
- /**
153
- * Wait for a lockfile to appear, returning its parsed contents.
154
- */
155
- async function waitForLockfile(
156
- lockfilePath: string,
157
- timeoutMs: number = 30000
158
- ): Promise<{ port: number; pid: number }> {
159
- const start = Date.now();
160
- while (Date.now() - start < timeoutMs) {
161
- if (existsSync(lockfilePath)) {
162
- try {
163
- const content = readFileSync(lockfilePath, "utf-8");
164
- return JSON.parse(content);
165
- } catch {
166
- // File may be partially written, retry
167
- }
168
- }
169
- await new Promise((r) => setTimeout(r, 200));
170
- }
171
- throw new Error(`Lockfile not found at ${lockfilePath} after ${timeoutMs}ms`);
172
- }
173
-
174
- /**
175
- * Wait for server health endpoint to respond.
176
- */
177
- async function waitForHealth(baseUrl: string, timeoutMs: number = 30000): Promise<void> {
178
- const start = Date.now();
179
- while (Date.now() - start < timeoutMs) {
180
- try {
181
- const res = await fetch(`${baseUrl}/health`);
182
- if (res.ok) return;
183
- } catch {
184
- // Server not ready yet
185
- }
186
- await new Promise((r) => setTimeout(r, 200));
187
- }
188
- throw new Error(`Server health check failed after ${timeoutMs}ms`);
189
- }
190
-
191
- /**
192
- * Collect stderr output from a subprocess.
193
- */
194
- async function collectStderr(
195
- proc: Subprocess,
196
- timeoutMs: number = 10000
197
- ): Promise<string> {
198
- const reader = (proc.stderr as ReadableStream).getReader();
199
- const decoder = new TextDecoder();
200
- let output = "";
201
- const start = Date.now();
202
-
203
- while (Date.now() - start < timeoutMs) {
204
- const result = await Promise.race([
205
- reader.read(),
206
- new Promise<{ done: true; value: undefined }>((r) =>
207
- setTimeout(() => r({ done: true, value: undefined }), timeoutMs - (Date.now() - start))
208
- ),
209
- ]);
210
-
211
- if (result.done) break;
212
- if (result.value) output += decoder.decode(result.value);
213
- }
214
-
215
- reader.releaseLock();
216
- return output;
217
- }
218
-
219
- // ── Main Test Sections ──────────────────────────────────────────────
220
-
221
- async function main(): Promise<void> {
222
- const tmpDir = mkdtempSync(join(tmpdir(), "vector-memory-smoke-"));
223
- const dbPath = join(tmpDir, "smoke-test.db");
224
- const sessionsPath = join(tmpDir, "sessions");
225
- const lockfilePath = join(tmpDir, ".vector-memory", "server.lock");
226
-
227
- console.log(`\n🔬 vector-memory-mcp 2.0 Smoke Test`);
228
- console.log(` Temp dir: ${tmpDir}\n`);
229
-
230
- // Pick a random high port to avoid conflicts with running servers
231
- const smokePort = 30000 + Math.floor(Math.random() * 30000);
232
-
233
- let proc: Subprocess | null = null;
234
- let baseUrl = "";
235
- let sessionId = "";
236
- let storedMemoryId = "";
237
-
238
- try {
239
- // ────────────────────────────────────────────────────────────────
240
- // Section 1: Server Startup & Health
241
- // ────────────────────────────────────────────────────────────────
242
- console.log("§1 Server Startup & Health");
243
-
244
- proc = spawn(
245
- [
246
- "bun", "run", SERVER_PATH,
247
- "--db-file", dbPath,
248
- "--port", String(smokePort),
249
- "--enable-history",
250
- "--history-path", sessionsPath,
251
- ],
252
- {
253
- stdin: "pipe",
254
- stdout: "pipe",
255
- stderr: "pipe",
256
- cwd: tmpDir,
257
- }
258
- );
259
-
260
- // Wait for lockfile
261
- const lockData = await waitForLockfile(lockfilePath);
262
- assert(lockData.port > 0, "Lockfile contains valid port");
263
- assert(lockData.pid === proc.pid, "Lockfile PID matches spawned process");
264
-
265
- baseUrl = `http://127.0.0.1:${lockData.port}`;
266
- await waitForHealth(baseUrl);
267
-
268
- // Health check
269
- const healthRes = await httpGet(baseUrl, "/health");
270
- const health = await healthRes.json() as any;
271
-
272
- assert(health.status === "ok", "GET /health returns status: ok");
273
- assert(health.config.historyEnabled === true, "Health reports historyEnabled: true");
274
- assertContains(health.config.dbPath, "smoke-test.db", "Health reports correct dbPath");
275
-
276
- // ────────────────────────────────────────────────────────────────
277
- // Section 2: Memory Lifecycle (HTTP)
278
- // ────────────────────────────────────────────────────────────────
279
- console.log("\n§2 Memory Lifecycle (HTTP)");
280
-
281
- // Initialize MCP session for tool calls
282
- sessionId = await initMcpSession(baseUrl);
283
- assert(sessionId.length > 0, "MCP session initialized with session ID");
284
-
285
- // Store via HTTP endpoint
286
- const storeRes = await httpPost(baseUrl, "/store", {
287
- content: "The velocity of an unladen swallow is approximately 11 m/s",
288
- metadata: { category: "ornithology", source: "smoke-test" },
289
- });
290
- const storeBody = await storeRes.json() as any;
291
- assert(storeBody.id !== undefined, "POST /store returns memory ID");
292
- storedMemoryId = storeBody.id;
293
-
294
- // Search via HTTP
295
- const searchRes = await httpPost(baseUrl, "/search", {
296
- query: "unladen swallow velocity",
297
- intent: "fact_check",
298
- });
299
- const searchBody = await searchRes.json() as any;
300
- assert(searchBody.count > 0, "POST /search finds stored memory");
301
- assert(
302
- searchBody.results.some((r: any) => r.id === storedMemoryId),
303
- "Search results contain the stored memory ID"
304
- );
305
-
306
- // Get via HTTP
307
- const getRes = await httpGet(baseUrl, `/memories/${storedMemoryId}`);
308
- const getBody = await getRes.json() as any;
309
- assert(getBody.id === storedMemoryId, "GET /memories/:id returns correct memory");
310
- assertContains(
311
- getBody.content,
312
- "unladen swallow",
313
- "GET /memories/:id content matches"
314
- );
315
-
316
- // Delete via HTTP
317
- const deleteRes = await httpDelete(baseUrl, `/memories/${storedMemoryId}`);
318
- const deleteBody = await deleteRes.json() as any;
319
- assert(deleteBody.deleted === true, "DELETE /memories/:id returns deleted: true");
320
-
321
- // Search should NOT find deleted memory
322
- const searchAfterDelete = await httpPost(baseUrl, "/search", {
323
- query: "unladen swallow velocity",
324
- intent: "fact_check",
325
- });
326
- const afterDeleteBody = await searchAfterDelete.json() as any;
327
- const foundDeleted = afterDeleteBody.results.some(
328
- (r: any) => r.id === storedMemoryId
329
- );
330
- assert(!foundDeleted, "Deleted memory not found in regular search");
331
-
332
- // Search with include_deleted via MCP
333
- const searchWithDeleted = await mcpCall(baseUrl, sessionId, "search_memories", {
334
- query: "unladen swallow velocity",
335
- intent: "fact_check",
336
- reason_for_search: "smoke test: verify soft-delete recovery",
337
- include_deleted: true,
338
- });
339
- assertContains(
340
- searchWithDeleted,
341
- storedMemoryId,
342
- "Deleted memory found with include_deleted via MCP"
343
- );
344
-
345
- // ────────────────────────────────────────────────────────────────
346
- // Section 3: Waypoint Lifecycle
347
- // ────────────────────────────────────────────────────────────────
348
- console.log("\n§3 Waypoint Lifecycle");
349
-
350
- // Store a memory to reference from the waypoint
351
- const wpStoreResult = await mcpCall(baseUrl, sessionId, "store_memories", {
352
- memories: [
353
- {
354
- content: "Waypoint reference: migration to sqlite-vec is complete",
355
- metadata: { phase: "migration" },
356
- },
357
- ],
358
- });
359
- const wpMemoryId = extractMemoryId(wpStoreResult);
360
- assert(wpMemoryId.length > 0, "Stored waypoint reference memory");
361
-
362
- // Set waypoint via MCP
363
- const setWpResult = await mcpCall(baseUrl, sessionId, "set_waypoint", {
364
- project: "smoke-test",
365
- branch: "main",
366
- summary: "Completed sqlite-vec migration smoke test",
367
- next_steps: ["verify retrieval", "run benchmarks"],
368
- memory_ids: [wpMemoryId],
369
- });
370
- assertContains(setWpResult, "Waypoint", "set_waypoint returns confirmation");
371
-
372
- // Get waypoint via MCP
373
- const getWpResult = await mcpCall(baseUrl, sessionId, "get_waypoint", {});
374
- assertContains(
375
- getWpResult,
376
- "sqlite-vec migration",
377
- "get_waypoint returns summary"
378
- );
379
- assertContains(
380
- getWpResult,
381
- "verify retrieval",
382
- "get_waypoint returns next_steps"
383
- );
384
-
385
- // Get waypoint via HTTP
386
- const wpHttpRes = await httpGet(baseUrl, "/waypoint");
387
- const wpHttpBody = await wpHttpRes.json() as any;
388
- assert(wpHttpRes.status === 200, "GET /waypoint returns 200");
389
- assert(
390
- wpHttpBody.referencedMemories?.length > 0,
391
- "GET /waypoint includes referencedMemories"
392
- );
393
- assertContains(
394
- wpHttpBody.referencedMemories[0].content,
395
- "sqlite-vec",
396
- "Referenced memory content matches"
397
- );
398
-
399
- // ────────────────────────────────────────────────────────────────
400
- // Section 4: Conversation History Indexing
401
- // ────────────────────────────────────────────────────────────────
402
- console.log("\n§4 Conversation History Indexing");
403
-
404
- // Create a fake Claude Code session JSONL file
405
- const fakeSessionId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
406
- const projectDir = join(sessionsPath, "-tmp-smoke-project");
407
- const sessionDir = join(projectDir, fakeSessionId);
408
- mkdirSync(sessionDir, { recursive: true });
409
-
410
- const now = new Date().toISOString();
411
- const sessionLines = [
412
- JSON.stringify({
413
- type: "user",
414
- sessionId: fakeSessionId,
415
- timestamp: now,
416
- uuid: "msg-001",
417
- message: {
418
- role: "user",
419
- content: "How does the sqlite-vec extension handle vector similarity search?",
420
- },
421
- }),
422
- JSON.stringify({
423
- type: "assistant",
424
- sessionId: fakeSessionId,
425
- timestamp: now,
426
- uuid: "msg-002",
427
- message: {
428
- role: "assistant",
429
- content: [
430
- {
431
- type: "text",
432
- text: "The sqlite-vec extension uses a virtual table to store float32 vectors and supports cosine distance for similarity queries.",
433
- },
434
- ],
435
- },
436
- }),
437
- JSON.stringify({
438
- type: "user",
439
- sessionId: fakeSessionId,
440
- timestamp: now,
441
- uuid: "msg-003",
442
- message: {
443
- role: "user",
444
- content: "What about full-text search integration?",
445
- },
446
- }),
447
- JSON.stringify({
448
- type: "assistant",
449
- sessionId: fakeSessionId,
450
- timestamp: now,
451
- uuid: "msg-004",
452
- message: {
453
- role: "assistant",
454
- content: [
455
- {
456
- type: "text",
457
- text: "We use FTS5 alongside sqlite-vec for hybrid search, combining vector similarity with keyword matching for better recall.",
458
- },
459
- ],
460
- },
461
- }),
462
- ];
463
-
464
- writeFileSync(
465
- join(sessionDir, `${fakeSessionId}.jsonl`),
466
- sessionLines.join("\n") + "\n"
467
- );
468
-
469
- // Index via MCP
470
- const indexResult = await mcpCall(baseUrl, sessionId, "index_conversations", {
471
- path: sessionsPath,
472
- });
473
- assertContains(indexResult, "Indexed", "index_conversations returns indexed count (MCP)");
474
- // Check it didn't report 0 indexed
475
- assert(
476
- !indexResult.includes("Indexed: 0"),
477
- "index_conversations indexed at least 1 session (MCP)"
478
- );
479
-
480
- // Create a second session for HTTP endpoint test
481
- const httpSessionId = "b2c3d4e5-f6a7-8901-bcde-f12345678901";
482
- const httpSessionDir = join(projectDir, httpSessionId);
483
- mkdirSync(httpSessionDir, { recursive: true });
484
- writeFileSync(
485
- join(httpSessionDir, `${httpSessionId}.jsonl`),
486
- [
487
- JSON.stringify({
488
- type: "user", sessionId: httpSessionId, timestamp: now, uuid: "msg-h01",
489
- message: { role: "user", content: "How does the warmup script download the ONNX model?" },
490
- }),
491
- JSON.stringify({
492
- type: "assistant", sessionId: httpSessionId, timestamp: now, uuid: "msg-h02",
493
- message: { role: "assistant", content: [{ type: "text", text: "The warmup script uses @huggingface/transformers to download and cache the ONNX model on first run." }] },
494
- }),
495
- ].join("\n") + "\n"
496
- );
497
-
498
- // Index via HTTP endpoint (POST /index-conversations)
499
- const httpIndexRes = await httpPost(baseUrl, "/index-conversations", {
500
- path: sessionsPath,
501
- });
502
- const httpIndexBody = await httpIndexRes.json() as any;
503
- assert(httpIndexRes.status === 200, "POST /index-conversations returns 200");
504
- assert(httpIndexBody.indexed >= 1, "POST /index-conversations indexed at least 1 new session");
505
-
506
- // Search history
507
- const historySearch = await mcpCall(baseUrl, sessionId, "search_memories", {
508
- query: "sqlite-vec vector similarity search",
509
- intent: "fact_check",
510
- reason_for_search: "smoke test: verify conversation history search",
511
- history_only: true,
512
- });
513
- assertContains(
514
- historySearch,
515
- "sqlite-vec",
516
- "history_only search returns conversation content"
517
- );
518
-
519
- // List indexed sessions
520
- const listResult = await mcpCall(baseUrl, sessionId, "list_indexed_sessions", {});
521
- assertContains(
522
- listResult,
523
- fakeSessionId,
524
- "list_indexed_sessions shows our session"
525
- );
526
-
527
- // Reindex session
528
- const reindexResult = await mcpCall(baseUrl, sessionId, "reindex_session", {
529
- session_id: fakeSessionId,
530
- });
531
- assertContains(
532
- reindexResult,
533
- "success",
534
- "reindex_session reports success"
535
- );
536
-
537
- // ────────────────────────────────────────────────────────────────
538
- // Section 5: Memory Usefulness Voting
539
- // ────────────────────────────────────────────────────────────────
540
- console.log("\n§5 Memory Usefulness Voting");
541
-
542
- // Store a fresh memory for voting
543
- const voteStoreResult = await mcpCall(baseUrl, sessionId, "store_memories", {
544
- memories: [
545
- {
546
- content: "Bun is faster than Node.js for starting processes",
547
- metadata: { topic: "runtime" },
548
- },
549
- ],
550
- });
551
- const voteMemoryId = extractMemoryId(voteStoreResult);
552
-
553
- // Vote useful
554
- const voteUpResult = await mcpCall(
555
- baseUrl,
556
- sessionId,
557
- "report_memory_usefulness",
558
- { memory_id: voteMemoryId, useful: true }
559
- );
560
- assert(
561
- !voteUpResult.startsWith("ERROR"),
562
- "report_memory_usefulness(useful: true) succeeds"
563
- );
564
-
565
- // Vote not useful
566
- const voteDownResult = await mcpCall(
567
- baseUrl,
568
- sessionId,
569
- "report_memory_usefulness",
570
- { memory_id: voteMemoryId, useful: false }
571
- );
572
- assert(
573
- !voteDownResult.startsWith("ERROR"),
574
- "report_memory_usefulness(useful: false) succeeds"
575
- );
576
-
577
- // ────────────────────────────────────────────────────────────────
578
- // Section 6: Lockfile Cleanup
579
- // ────────────────────────────────────────────────────────────────
580
- console.log("\n§6 Lockfile Cleanup");
581
-
582
- assert(existsSync(lockfilePath), "Lockfile exists before shutdown");
583
-
584
- // Send SIGTERM for graceful shutdown
585
- proc.kill("SIGTERM");
586
- const exitCode = await proc.exited;
587
- assert(exitCode === 0, `Server exited with code 0 (got ${exitCode})`);
588
-
589
- // Give a moment for cleanup
590
- await new Promise((r) => setTimeout(r, 500));
591
- assert(!existsSync(lockfilePath), "Lockfile removed after SIGTERM");
592
-
593
- proc = null; // Mark as cleaned up
594
-
595
- // ────────────────────────────────────────────────────────────────
596
- // Section 7: Migration Detection
597
- // ────────────────────────────────────────────────────────────────
598
- console.log("\n§7 Migration Detection");
599
-
600
- // Create a fake LanceDB directory
601
- const fakeLanceDir = join(tmpDir, "fake-lance.db");
602
- mkdirSync(join(fakeLanceDir, "memories.lance"), { recursive: true });
603
-
604
- const migProc = spawn(
605
- ["bun", "run", SERVER_PATH, "--db-file", fakeLanceDir],
606
- {
607
- stdin: "pipe",
608
- stdout: "pipe",
609
- stderr: "pipe",
610
- cwd: tmpDir,
611
- }
612
- );
613
-
614
- const migExitCode = await migProc.exited;
615
- assert(migExitCode === 1, `Server exits with code 1 on LanceDB detection (got ${migExitCode})`);
616
-
617
- // Read stderr for the error message
618
- const migStderr = await new Response(migProc.stderr as ReadableStream).text();
619
- assertContains(
620
- migStderr,
621
- "Legacy LanceDB data detected",
622
- "Stderr contains legacy data warning"
623
- );
624
- assertContains(
625
- migStderr,
626
- "migrate",
627
- "Stderr mentions migrate command"
628
- );
629
-
630
- // ────────────────────────────────────────────────────────────────
631
- // Section 8: Migrate Subcommand (No Real Data)
632
- // ────────────────────────────────────────────────────────────────
633
- console.log("\n§8 Migrate Subcommand (No Real Data)");
634
-
635
- const nonexistentPath = join(tmpDir, "does-not-exist.db");
636
- const migCmdProc = spawn(
637
- ["bun", "run", SERVER_PATH, "migrate", "--db-file", nonexistentPath],
638
- {
639
- stdin: "pipe",
640
- stdout: "pipe",
641
- stderr: "pipe",
642
- cwd: tmpDir,
643
- }
644
- );
645
-
646
- const migCmdExit = await migCmdProc.exited;
647
- // Note: main() catches errors with .catch(console.error), so exit code may be 0
648
- // even on failure. We verify via stderr content instead.
649
- const migCmdStderr = await new Response(migCmdProc.stderr as ReadableStream).text();
650
- assert(
651
- migCmdStderr.includes("Source not found") ||
652
- migCmdStderr.includes("not a directory") ||
653
- migCmdStderr.includes("not found") ||
654
- migCmdStderr.includes("Error"),
655
- "Migrate with nonexistent source produces error message"
656
- );
657
-
658
- } catch (error) {
659
- failed++;
660
- const msg = error instanceof Error ? error.message : String(error);
661
- failures.push(`FATAL: ${msg}`);
662
- console.log(`\n 💥 FATAL ERROR: ${msg}`);
663
- if (error instanceof Error && error.stack) {
664
- console.log(` ${error.stack.split("\n").slice(1, 4).join("\n ")}`);
665
- }
666
- } finally {
667
- // Clean up server if still running
668
- if (proc) {
669
- try {
670
- proc.kill("SIGKILL");
671
- await proc.exited;
672
- } catch {
673
- // Already exited
674
- }
675
- }
676
-
677
- // Clean up temp directory
678
- try {
679
- rmSync(tmpDir, { recursive: true, force: true });
680
- } catch {
681
- console.log(` ⚠️ Could not clean up ${tmpDir}`);
682
- }
683
- }
684
-
685
- // ── Summary ───────────────────────────────────────────────────────
686
- console.log(`\n${"─".repeat(50)}`);
687
- console.log(` Results: ${passed} passed, ${failed} failed`);
688
- if (failures.length > 0) {
689
- console.log(`\n Failures:`);
690
- for (const f of failures) {
691
- console.log(` - ${f}`);
692
- }
693
- }
694
- console.log(`${"─".repeat(50)}\n`);
695
-
696
- process.exit(failed > 0 ? 1 : 0);
697
- }
698
-
699
- main();