@totalreclaw/totalreclaw 1.6.0 → 3.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,1123 +0,0 @@
1
- /**
2
- * Unit tests for import adapters.
3
- *
4
- * Run with: npx tsx import-adapters/import-adapters.test.ts
5
- *
6
- * Uses TAP-style output (no test framework dependency).
7
- *
8
- * ChatGPT and Claude adapters return conversation CHUNKS (not facts).
9
- * Fact extraction is delegated to the LLM via extractFacts().
10
- * Mem0 and MCP Memory adapters return pre-structured facts.
11
- */
12
-
13
- import { Mem0Adapter } from './mem0-adapter.js';
14
- import { MCPMemoryAdapter } from './mcp-memory-adapter.js';
15
- import { ChatGPTAdapter } from './chatgpt-adapter.js';
16
- import { ClaudeAdapter } from './claude-adapter.js';
17
- import { BaseImportAdapter } from './base-adapter.js';
18
- import { getAdapter } from './index.js';
19
- import type {
20
- NormalizedFact,
21
- AdapterParseResult,
22
- ImportSource,
23
- ProgressCallback,
24
- } from './types.js';
25
-
26
- // ---------------------------------------------------------------------------
27
- // TAP Helpers
28
- // ---------------------------------------------------------------------------
29
-
30
- let passed = 0;
31
- let failed = 0;
32
- let testNum = 0;
33
-
34
- function assert(condition: boolean, message: string): void {
35
- testNum++;
36
- if (condition) {
37
- passed++;
38
- console.log(`ok ${testNum} - ${message}`);
39
- } else {
40
- failed++;
41
- console.log(`not ok ${testNum} - ${message}`);
42
- }
43
- }
44
-
45
- function assertThrows(fn: () => void, message: string): void {
46
- try {
47
- fn();
48
- failed++;
49
- testNum++;
50
- console.log(`not ok ${testNum} - ${message} (did not throw)`);
51
- } catch {
52
- passed++;
53
- testNum++;
54
- console.log(`ok ${testNum} - ${message}`);
55
- }
56
- }
57
-
58
- // ---------------------------------------------------------------------------
59
- // Concrete subclass to test protected BaseImportAdapter.validateFact()
60
- // ---------------------------------------------------------------------------
61
-
62
- class TestAdapter extends BaseImportAdapter {
63
- readonly source: ImportSource = 'mem0';
64
- readonly displayName = 'Test Adapter';
65
-
66
- async parse(): Promise<AdapterParseResult> {
67
- return { facts: [], chunks: [], totalMessages: 0, warnings: [], errors: [] };
68
- }
69
-
70
- // Expose protected methods for testing
71
- public testValidateFact(fact: Partial<NormalizedFact>): NormalizedFact | null {
72
- return this.validateFact(fact);
73
- }
74
-
75
- public testValidateFacts(
76
- rawFacts: Partial<NormalizedFact>[],
77
- ): { facts: NormalizedFact[]; invalidCount: number } {
78
- return this.validateFacts(rawFacts);
79
- }
80
- }
81
-
82
- // ---------------------------------------------------------------------------
83
- // Tests
84
- // ---------------------------------------------------------------------------
85
-
86
- async function runTests(): Promise<void> {
87
- // =========================================================================
88
- // Mem0Adapter
89
- // =========================================================================
90
-
91
- console.log('# Mem0Adapter');
92
-
93
- // --- parses API response format ---
94
- {
95
- const content = JSON.stringify({
96
- results: [
97
- { id: 'mem-1', memory: 'User prefers dark mode', categories: ['preference'] },
98
- { id: 'mem-2', memory: 'User works at Acme Corp', categories: ['fact'] },
99
- ],
100
- });
101
-
102
- const adapter = new Mem0Adapter();
103
- const result = await adapter.parse({ content });
104
-
105
- assert(result.facts.length === 2, 'Mem0: API response format yields 2 facts');
106
- assert(result.facts[0].text === 'User prefers dark mode', 'Mem0: first fact text correct');
107
- assert(result.facts[0].type === 'preference', 'Mem0: preference category mapped');
108
- assert(result.facts[0].source === 'mem0', 'Mem0: source is mem0');
109
- assert(result.facts[1].type === 'fact', 'Mem0: fact category mapped');
110
- assert(result.facts[1].sourceId === 'mem-2', 'Mem0: sourceId preserved');
111
- assert(result.chunks.length === 0, 'Mem0: no chunks (pre-structured source)');
112
- }
113
-
114
- // --- parses export file format ---
115
- {
116
- const content = JSON.stringify({
117
- export_date: '2026-03-10',
118
- memories: [
119
- { id: 'mem-1', memory: 'User likes TypeScript' },
120
- ],
121
- });
122
-
123
- const adapter = new Mem0Adapter();
124
- const result = await adapter.parse({ content });
125
-
126
- assert(result.facts.length === 1, 'Mem0: export file format yields 1 fact');
127
- assert(result.facts[0].text === 'User likes TypeScript', 'Mem0: export fact text correct');
128
- }
129
-
130
- // --- parses bare array format ---
131
- {
132
- const content = JSON.stringify([
133
- { id: 'mem-1', memory: 'User prefers Python' },
134
- { id: 'mem-2', memory: 'User dislikes Java' },
135
- ]);
136
-
137
- const adapter = new Mem0Adapter();
138
- const result = await adapter.parse({ content });
139
-
140
- assert(result.facts.length === 2, 'Mem0: bare array yields 2 facts');
141
- assert(result.facts[0].text === 'User prefers Python', 'Mem0: bare array first fact correct');
142
- }
143
-
144
- // --- skips empty/short memories ---
145
- {
146
- const content = JSON.stringify({
147
- results: [
148
- { id: 'mem-1', memory: '' },
149
- { id: 'mem-2', memory: 'Valid fact here' },
150
- { id: 'mem-3', memory: 'ab' }, // too short (< 3 chars)
151
- ],
152
- });
153
-
154
- const adapter = new Mem0Adapter();
155
- const result = await adapter.parse({ content });
156
-
157
- assert(result.facts.length === 1, 'Mem0: skips empty/short memories, keeps 1 valid');
158
- assert(result.facts[0].text === 'Valid fact here', 'Mem0: only valid fact kept');
159
- const hasWarning = result.warnings.some((w) => w.includes('2 memories had invalid'));
160
- assert(hasWarning, 'Mem0: warning about 2 invalid memories');
161
- }
162
-
163
- // --- returns errors on invalid JSON ---
164
- {
165
- const adapter = new Mem0Adapter();
166
- const result = await adapter.parse({ content: 'not json {{{' });
167
-
168
- assert(result.facts.length === 0, 'Mem0: invalid JSON yields 0 facts');
169
- assert(result.errors.length > 0, 'Mem0: invalid JSON produces error');
170
- assert(
171
- result.errors[0].includes('Failed to parse Mem0 JSON'),
172
- 'Mem0: error message mentions JSON parse failure',
173
- );
174
- }
175
-
176
- // --- returns error when no content or api_key ---
177
- {
178
- const adapter = new Mem0Adapter();
179
- const result = await adapter.parse({});
180
-
181
- assert(result.facts.length === 0, 'Mem0: no input yields 0 facts');
182
- assert(result.errors.length > 0, 'Mem0: no input produces error');
183
- assert(
184
- result.errors[0].includes('requires either content'),
185
- 'Mem0: error message mentions required input',
186
- );
187
- }
188
-
189
- // --- category mapping ---
190
- {
191
- const content = JSON.stringify({
192
- results: [
193
- { id: '1', memory: 'User likes hiking', categories: ['like'] },
194
- { id: '2', memory: 'User dislikes rain', categories: ['dislike'] },
195
- { id: '3', memory: 'Graduated in 2020', categories: ['biographical'] },
196
- { id: '4', memory: 'Wants to learn Rust', categories: ['objective'] },
197
- { id: '5', memory: 'Visited Paris in 2023', categories: ['event'] },
198
- { id: '6', memory: 'Chose React over Vue', categories: ['decision'] },
199
- { id: '7', memory: 'Some unknown category', categories: ['zzz_unknown'] },
200
- ],
201
- });
202
-
203
- const adapter = new Mem0Adapter();
204
- const result = await adapter.parse({ content });
205
-
206
- assert(result.facts[0].type === 'preference', 'Mem0: "like" -> preference');
207
- assert(result.facts[1].type === 'preference', 'Mem0: "dislike" -> preference');
208
- assert(result.facts[2].type === 'fact', 'Mem0: "biographical" -> fact');
209
- assert(result.facts[3].type === 'goal', 'Mem0: "objective" -> goal');
210
- assert(result.facts[4].type === 'episodic', 'Mem0: "event" -> episodic');
211
- assert(result.facts[5].type === 'decision', 'Mem0: "decision" -> decision');
212
- assert(result.facts[6].type === 'fact', 'Mem0: unknown category defaults to fact');
213
- }
214
-
215
- // --- importance defaults to 6 ---
216
- {
217
- const content = JSON.stringify({
218
- results: [
219
- { id: 'mem-1', memory: 'User prefers dark mode' },
220
- ],
221
- });
222
-
223
- const adapter = new Mem0Adapter();
224
- const result = await adapter.parse({ content });
225
-
226
- assert(result.facts[0].importance === 6, 'Mem0: default importance is 6');
227
- }
228
-
229
- // --- handles unrecognized format ---
230
- {
231
- const content = JSON.stringify({ some_key: 'some_value' });
232
-
233
- const adapter = new Mem0Adapter();
234
- const result = await adapter.parse({ content });
235
-
236
- assert(result.facts.length === 0, 'Mem0: unrecognized format yields 0 facts');
237
- assert(
238
- result.errors.some((e) => e.includes('Unrecognized Mem0 format')),
239
- 'Mem0: unrecognized format error message',
240
- );
241
- }
242
-
243
- // =========================================================================
244
- // MCPMemoryAdapter
245
- // =========================================================================
246
-
247
- console.log('# MCPMemoryAdapter');
248
-
249
- // --- parses entities with observations ---
250
- {
251
- const content = [
252
- JSON.stringify({ type: 'entity', name: 'John', entityType: 'person', observations: ['Works at Acme Corp', 'Prefers TypeScript'] }),
253
- JSON.stringify({ type: 'entity', name: 'Project Alpha', entityType: 'project', observations: ['Uses React'] }),
254
- ].join('\n');
255
-
256
- const adapter = new MCPMemoryAdapter();
257
- const result = await adapter.parse({ content });
258
-
259
- assert(result.facts.length === 3, 'MCP: 2 entities with 3 total observations -> 3 facts');
260
- assert(result.facts[0].text.includes('John'), 'MCP: first fact includes entity name');
261
- assert(result.facts[0].text.includes('Acme Corp'), 'MCP: first fact includes observation');
262
- assert(result.facts[1].text.includes('TypeScript'), 'MCP: second fact includes TypeScript');
263
- assert(result.facts[0].source === 'mcp-memory', 'MCP: source is mcp-memory');
264
- assert(result.chunks.length === 0, 'MCP: no chunks (pre-structured source)');
265
- }
266
-
267
- // --- contextualizes observations correctly ---
268
- {
269
- const content = [
270
- JSON.stringify({
271
- type: 'entity', name: 'John', entityType: 'person',
272
- observations: [
273
- 'works at Acme Corp', // lowercase verb -> "John works at Acme Corp"
274
- 'John likes TypeScript', // already starts with name -> unchanged
275
- 'He prefers React', // pronoun -> replaced: "John prefers React"
276
- 'An avid hiker', // uppercase start -> "John: An avid hiker"
277
- ],
278
- }),
279
- ].join('\n');
280
-
281
- const adapter = new MCPMemoryAdapter();
282
- const result = await adapter.parse({ content });
283
-
284
- assert(result.facts.length === 4, 'MCP: 4 observations -> 4 facts');
285
- assert(result.facts[0].text === 'John works at Acme Corp', 'MCP: lowercase verb prefixed with entity name');
286
- assert(result.facts[1].text === 'John likes TypeScript', 'MCP: already has entity name, unchanged');
287
- assert(result.facts[2].text === 'John prefers React', 'MCP: pronoun "He" replaced with entity name');
288
- assert(result.facts[3].text === 'John: An avid hiker', 'MCP: uppercase standalone sentence prefixed with colon');
289
- }
290
-
291
- // --- parses relations ---
292
- {
293
- const content = [
294
- JSON.stringify({ type: 'entity', name: 'John', entityType: 'person', observations: ['Developer'] }),
295
- JSON.stringify({ type: 'entity', name: 'Project Alpha', entityType: 'project', observations: ['Deadline March'] }),
296
- JSON.stringify({ type: 'relation', from: 'John', to: 'Project Alpha', relationType: 'works_on' }),
297
- ].join('\n');
298
-
299
- const adapter = new MCPMemoryAdapter();
300
- const result = await adapter.parse({ content });
301
-
302
- // 2 observations + 1 relation = 3 facts
303
- assert(result.facts.length === 3, 'MCP: 2 observations + 1 relation = 3 facts');
304
- assert(
305
- result.facts[2].text === 'John works on Project Alpha',
306
- 'MCP: relation converted to human-readable text',
307
- );
308
- assert(result.facts[2].type === 'fact', 'MCP: relation type defaults to fact');
309
- }
310
-
311
- // --- later entity overrides earlier (append-only semantics) ---
312
- {
313
- const content = [
314
- JSON.stringify({ type: 'entity', name: 'John', entityType: 'person', observations: ['Old observation'] }),
315
- JSON.stringify({ type: 'entity', name: 'John', entityType: 'person', observations: ['New observation'] }),
316
- ].join('\n');
317
-
318
- const adapter = new MCPMemoryAdapter();
319
- const result = await adapter.parse({ content });
320
-
321
- // Only the later entity should be used (Map overwrites)
322
- assert(result.facts.length === 1, 'MCP: duplicate entity keeps only latest');
323
- assert(
324
- result.facts[0].text.includes('New observation'),
325
- 'MCP: latest entity observations used',
326
- );
327
- }
328
-
329
- // --- warns on orphaned relations ---
330
- {
331
- const content = [
332
- JSON.stringify({ type: 'relation', from: 'Unknown', to: 'Also Unknown', relationType: 'knows' }),
333
- ].join('\n');
334
-
335
- const adapter = new MCPMemoryAdapter();
336
- const result = await adapter.parse({ content });
337
-
338
- const hasWarning = result.warnings.some((w) => w.includes('unknown entity'));
339
- assert(hasWarning, 'MCP: warns on orphaned relation with unknown entities');
340
- // Orphaned relation should not produce a fact
341
- assert(result.facts.length === 0, 'MCP: orphaned relation produces no facts');
342
- }
343
-
344
- // --- handles empty/blank lines in JSONL ---
345
- {
346
- const content = [
347
- '',
348
- JSON.stringify({ type: 'entity', name: 'Alice', entityType: 'person', observations: ['Likes coffee'] }),
349
- ' ',
350
- JSON.stringify({ type: 'entity', name: 'Bob', entityType: 'person', observations: ['Likes tea'] }),
351
- '',
352
- ].join('\n');
353
-
354
- const adapter = new MCPMemoryAdapter();
355
- const result = await adapter.parse({ content });
356
-
357
- assert(result.facts.length === 2, 'MCP: blank lines in JSONL are skipped');
358
- assert(result.errors.length === 0, 'MCP: blank lines do not produce errors');
359
- }
360
-
361
- // --- invalid JSON lines produce errors ---
362
- {
363
- const content = [
364
- JSON.stringify({ type: 'entity', name: 'Alice', entityType: 'person', observations: ['Valid'] }),
365
- 'this is not json',
366
- JSON.stringify({ type: 'entity', name: 'Bob', entityType: 'person', observations: ['Also valid'] }),
367
- ].join('\n');
368
-
369
- const adapter = new MCPMemoryAdapter();
370
- const result = await adapter.parse({ content });
371
-
372
- assert(result.facts.length === 2, 'MCP: valid facts parsed despite invalid line');
373
- assert(result.errors.length === 1, 'MCP: one error for the invalid JSON line');
374
- assert(result.errors[0].includes('Line 2'), 'MCP: error references correct line number');
375
- }
376
-
377
- // --- entity type mapping ---
378
- {
379
- const content = [
380
- JSON.stringify({ type: 'entity', name: 'React', entityType: 'tool', observations: ['Frontend framework'] }),
381
- JSON.stringify({ type: 'entity', name: 'Launch', entityType: 'goal', observations: ['Ship by March'] }),
382
- JSON.stringify({ type: 'entity', name: 'Meeting', entityType: 'event', observations: ['Quarterly review'] }),
383
- JSON.stringify({ type: 'entity', name: 'PickedAWS', entityType: 'decision', observations: ['Over GCP'] }),
384
- ].join('\n');
385
-
386
- const adapter = new MCPMemoryAdapter();
387
- const result = await adapter.parse({ content });
388
-
389
- assert(result.facts[0].type === 'preference', 'MCP: "tool" entityType -> preference');
390
- assert(result.facts[1].type === 'goal', 'MCP: "goal" entityType -> goal');
391
- assert(result.facts[2].type === 'episodic', 'MCP: "event" entityType -> episodic');
392
- assert(result.facts[3].type === 'decision', 'MCP: "decision" entityType -> decision');
393
- }
394
-
395
- // --- no content and no file_path (and no default file) ---
396
- {
397
- const adapter = new MCPMemoryAdapter();
398
- // Pass a non-existent file_path to trigger the file read error path
399
- const result = await adapter.parse({ file_path: '/tmp/definitely-does-not-exist-totalreclaw-test.jsonl' });
400
-
401
- assert(result.facts.length === 0, 'MCP: missing file yields 0 facts');
402
- assert(result.errors.length > 0, 'MCP: missing file produces error');
403
- }
404
-
405
- // --- relation type humanization ---
406
- {
407
- const content = [
408
- JSON.stringify({ type: 'entity', name: 'A', entityType: 'person', observations: ['exists'] }),
409
- JSON.stringify({ type: 'entity', name: 'B', entityType: 'project', observations: ['exists'] }),
410
- JSON.stringify({ type: 'relation', from: 'A', to: 'B', relationType: 'MEMBER_OF' }),
411
- ].join('\n');
412
-
413
- const adapter = new MCPMemoryAdapter();
414
- const result = await adapter.parse({ content });
415
-
416
- const relFact = result.facts.find((f) => f.tags?.includes('relation'));
417
- assert(
418
- relFact?.text === 'A is a member of B',
419
- `MCP: MEMBER_OF humanized to "is a member of" (got: ${relFact?.text})`,
420
- );
421
- }
422
-
423
- // =========================================================================
424
- // BaseImportAdapter (via TestAdapter)
425
- // =========================================================================
426
-
427
- console.log('# BaseImportAdapter');
428
-
429
- const testAdapter = new TestAdapter();
430
-
431
- // --- text validation: minimum 3 chars ---
432
- {
433
- assert(testAdapter.testValidateFact({ text: '' }) === null, 'Base: empty text returns null');
434
- assert(testAdapter.testValidateFact({ text: 'ab' }) === null, 'Base: 2-char text returns null');
435
- assert(testAdapter.testValidateFact({ text: ' ' }) === null, 'Base: whitespace-only text returns null');
436
- assert(testAdapter.testValidateFact({ text: 'a ' }) === null, 'Base: 1-char trimmed text returns null');
437
-
438
- const result = testAdapter.testValidateFact({ text: 'abc' });
439
- assert(result !== null, 'Base: 3-char text is valid');
440
- assert(result?.text === 'abc', 'Base: 3-char text preserved');
441
- }
442
-
443
- // --- text truncation at 512 chars ---
444
- {
445
- const longText = 'x'.repeat(600);
446
- const result = testAdapter.testValidateFact({ text: longText });
447
-
448
- assert(result !== null, 'Base: long text is valid');
449
- assert(result!.text.length === 512, `Base: text truncated to 512 chars (got ${result!.text.length})`);
450
- }
451
-
452
- // --- text is trimmed ---
453
- {
454
- const result = testAdapter.testValidateFact({ text: ' hello world ' });
455
- assert(result !== null, 'Base: padded text is valid');
456
- assert(result!.text === 'hello world', 'Base: text is trimmed');
457
- }
458
-
459
- // --- type normalization ---
460
- {
461
- const validTypes = ['fact', 'preference', 'decision', 'episodic', 'goal', 'context', 'summary'] as const;
462
-
463
- for (const t of validTypes) {
464
- const result = testAdapter.testValidateFact({ text: 'test fact', type: t });
465
- assert(result?.type === t, `Base: type "${t}" preserved`);
466
- }
467
-
468
- const invalidResult = testAdapter.testValidateFact({ text: 'test fact', type: 'bogus' as any });
469
- assert(invalidResult?.type === 'fact', 'Base: invalid type defaults to "fact"');
470
-
471
- const missingResult = testAdapter.testValidateFact({ text: 'test fact' });
472
- assert(missingResult?.type === 'fact', 'Base: missing type defaults to "fact"');
473
- }
474
-
475
- // --- importance scale conversion (0-1 -> 1-10) ---
476
- {
477
- // 0-1 scale conversion
478
- const r0 = testAdapter.testValidateFact({ text: 'test fact', importance: 0.0 });
479
- assert(r0?.importance === 1, `Base: importance 0.0 -> 1 (got ${r0?.importance})`);
480
-
481
- const r05 = testAdapter.testValidateFact({ text: 'test fact', importance: 0.5 });
482
- assert(r05?.importance === 5, `Base: importance 0.5 -> 5 (got ${r05?.importance})`);
483
-
484
- const r1 = testAdapter.testValidateFact({ text: 'test fact', importance: 1.0 });
485
- assert(r1?.importance === 10, `Base: importance 1.0 -> 10 (got ${r1?.importance})`);
486
-
487
- const r03 = testAdapter.testValidateFact({ text: 'test fact', importance: 0.3 });
488
- assert(r03?.importance === 3, `Base: importance 0.3 -> 3 (got ${r03?.importance})`);
489
-
490
- const r08 = testAdapter.testValidateFact({ text: 'test fact', importance: 0.8 });
491
- assert(r08?.importance === 8, `Base: importance 0.8 -> 8 (got ${r08?.importance})`);
492
- }
493
-
494
- // --- importance already on 1-10 scale ---
495
- {
496
- const r5 = testAdapter.testValidateFact({ text: 'test fact', importance: 5 });
497
- assert(r5?.importance === 5, `Base: importance 5 stays 5 (got ${r5?.importance})`);
498
-
499
- const r10 = testAdapter.testValidateFact({ text: 'test fact', importance: 10 });
500
- assert(r10?.importance === 10, `Base: importance 10 stays 10 (got ${r10?.importance})`);
501
-
502
- const r7 = testAdapter.testValidateFact({ text: 'test fact', importance: 7 });
503
- assert(r7?.importance === 7, `Base: importance 7 stays 7 (got ${r7?.importance})`);
504
- }
505
-
506
- // --- importance clamping ---
507
- {
508
- const rNeg = testAdapter.testValidateFact({ text: 'test fact', importance: -5 });
509
- assert(rNeg?.importance === 1, `Base: importance -5 clamped to 1 (got ${rNeg?.importance})`);
510
-
511
- const rHuge = testAdapter.testValidateFact({ text: 'test fact', importance: 100 });
512
- assert(rHuge?.importance === 10, `Base: importance 100 clamped to 10 (got ${rHuge?.importance})`);
513
- }
514
-
515
- // --- importance default ---
516
- {
517
- const rDefault = testAdapter.testValidateFact({ text: 'test fact' });
518
- assert(rDefault?.importance === 5, `Base: missing importance defaults to 5 (got ${rDefault?.importance})`);
519
- }
520
-
521
- // --- source defaults to adapter's source ---
522
- {
523
- const result = testAdapter.testValidateFact({ text: 'test fact' });
524
- assert(result?.source === 'mem0', 'Base: source defaults to adapter source');
525
-
526
- const withSource = testAdapter.testValidateFact({ text: 'test fact', source: 'mcp-memory' });
527
- assert(withSource?.source === 'mcp-memory', 'Base: explicit source preserved');
528
- }
529
-
530
- // --- validateFacts batch ---
531
- {
532
- const rawFacts: Partial<NormalizedFact>[] = [
533
- { text: 'Valid fact one' },
534
- { text: '' }, // invalid
535
- { text: 'Valid fact two' },
536
- { text: 'xy' }, // too short
537
- { text: 'Valid fact three' },
538
- ];
539
-
540
- const { facts, invalidCount } = testAdapter.testValidateFacts(rawFacts);
541
- assert(facts.length === 3, `Base: validateFacts keeps 3 valid out of 5 (got ${facts.length})`);
542
- assert(invalidCount === 2, `Base: validateFacts counts 2 invalid (got ${invalidCount})`);
543
- }
544
-
545
- // =========================================================================
546
- // ChatGPTAdapter — conversations.json (returns chunks, not facts)
547
- // =========================================================================
548
-
549
- console.log('# ChatGPTAdapter — conversations.json (chunks)');
550
-
551
- // --- returns conversation chunks with user + assistant messages ---
552
- {
553
- const conversations = [
554
- {
555
- id: 'conv-1',
556
- title: 'Test Conversation',
557
- create_time: 1700000000,
558
- mapping: {
559
- root: { id: 'root', message: null, parent: null, children: ['msg1'] },
560
- msg1: {
561
- id: 'msg1',
562
- message: {
563
- id: 'msg1',
564
- author: { role: 'user' },
565
- content: { content_type: 'text', parts: ['I work at Google as a software engineer'] },
566
- create_time: 1700000001,
567
- },
568
- parent: 'root',
569
- children: ['msg2'],
570
- },
571
- msg2: {
572
- id: 'msg2',
573
- message: {
574
- id: 'msg2',
575
- author: { role: 'assistant' },
576
- content: { content_type: 'text', parts: ['That sounds great! Tell me more about your work.'] },
577
- create_time: 1700000002,
578
- },
579
- parent: 'msg1',
580
- children: ['msg3'],
581
- },
582
- msg3: {
583
- id: 'msg3',
584
- message: {
585
- id: 'msg3',
586
- author: { role: 'user' },
587
- content: { content_type: 'text', parts: ['I prefer TypeScript over JavaScript for new projects'] },
588
- create_time: 1700000003,
589
- },
590
- parent: 'msg2',
591
- children: [],
592
- },
593
- },
594
- },
595
- ];
596
-
597
- const adapter = new ChatGPTAdapter();
598
- const result = await adapter.parse({ content: JSON.stringify(conversations) });
599
-
600
- assert(result.facts.length === 0, 'ChatGPT conv: no pre-extracted facts (uses chunks)');
601
- assert(result.chunks.length === 1, `ChatGPT conv: 1 conversation chunk (got ${result.chunks.length})`);
602
- assert(result.chunks[0].title === 'Test Conversation', 'ChatGPT conv: chunk title matches conversation title');
603
- assert(result.chunks[0].messages.length === 3, `ChatGPT conv: 3 messages (user + assistant) (got ${result.chunks[0].messages.length})`);
604
- assert(result.chunks[0].messages[0].role === 'user', 'ChatGPT conv: first message is user');
605
- assert(result.chunks[0].messages[0].text.includes('Google'), 'ChatGPT conv: first message text correct');
606
- assert(result.chunks[0].messages[1].role === 'assistant', 'ChatGPT conv: second message is assistant');
607
- assert(result.chunks[0].messages[2].role === 'user', 'ChatGPT conv: third message is user');
608
- assert(result.chunks[0].messages[2].text.includes('TypeScript'), 'ChatGPT conv: third message text correct');
609
- assert(result.chunks[0].timestamp !== undefined, 'ChatGPT conv: chunk has timestamp');
610
- assert(result.totalMessages === 3, `ChatGPT conv: totalMessages is 3 (got ${result.totalMessages})`);
611
- assert(result.errors.length === 0, 'ChatGPT conv: no errors');
612
- }
613
-
614
- // --- includes both user and assistant messages ---
615
- {
616
- const conversations = [
617
- {
618
- title: 'Context Test',
619
- mapping: {
620
- root: { id: 'root', message: null, parent: null, children: ['msg1'] },
621
- msg1: {
622
- id: 'msg1',
623
- message: {
624
- id: 'msg1',
625
- author: { role: 'user' },
626
- content: { content_type: 'text', parts: ['I want to migrate to TypeScript'] },
627
- },
628
- parent: 'root',
629
- children: ['msg2'],
630
- },
631
- msg2: {
632
- id: 'msg2',
633
- message: {
634
- id: 'msg2',
635
- author: { role: 'assistant' },
636
- content: { content_type: 'text', parts: ['TypeScript migration involves setting up tsconfig and converting files'] },
637
- },
638
- parent: 'msg1',
639
- children: [],
640
- },
641
- },
642
- },
643
- ];
644
-
645
- const adapter = new ChatGPTAdapter();
646
- const result = await adapter.parse({ content: JSON.stringify(conversations) });
647
-
648
- assert(result.chunks.length === 1, 'ChatGPT: includes assistant messages for context');
649
- assert(result.chunks[0].messages.length === 2, 'ChatGPT: both user and assistant in chunk');
650
- assert(result.chunks[0].messages[1].role === 'assistant', 'ChatGPT: assistant message preserved');
651
- }
652
-
653
- // --- skips system and tool messages ---
654
- {
655
- const conversations = [
656
- {
657
- title: 'Test',
658
- mapping: {
659
- root: { id: 'root', message: null, parent: null, children: ['msg1'] },
660
- msg1: {
661
- id: 'msg1',
662
- message: {
663
- id: 'msg1',
664
- author: { role: 'system' },
665
- content: { content_type: 'text', parts: ['You are a helpful assistant'] },
666
- },
667
- parent: 'root',
668
- children: ['msg2'],
669
- },
670
- msg2: {
671
- id: 'msg2',
672
- message: {
673
- id: 'msg2',
674
- author: { role: 'tool' },
675
- content: { content_type: 'text', parts: ['Tool output: search results...'] },
676
- },
677
- parent: 'msg1',
678
- children: ['msg3'],
679
- },
680
- msg3: {
681
- id: 'msg3',
682
- message: {
683
- id: 'msg3',
684
- author: { role: 'user' },
685
- content: { content_type: 'text', parts: ['I work at Google as a senior engineer'] },
686
- },
687
- parent: 'msg2',
688
- children: [],
689
- },
690
- },
691
- },
692
- ];
693
-
694
- const adapter = new ChatGPTAdapter();
695
- const result = await adapter.parse({ content: JSON.stringify(conversations) });
696
-
697
- assert(result.chunks.length === 1, 'ChatGPT: has 1 chunk');
698
- assert(result.chunks[0].messages.length === 1, 'ChatGPT: only user message (skips system + tool)');
699
- assert(result.chunks[0].messages[0].role === 'user', 'ChatGPT: the surviving message is user');
700
- }
701
-
702
- // --- chunks large conversations into batches of 20 ---
703
- {
704
- // Build a conversation with 45 messages
705
- const mapping: Record<string, any> = {
706
- root: { id: 'root', message: null, parent: null, children: ['msg-0'] },
707
- };
708
-
709
- for (let i = 0; i < 45; i++) {
710
- const role = i % 2 === 0 ? 'user' : 'assistant';
711
- mapping[`msg-${i}`] = {
712
- id: `msg-${i}`,
713
- message: {
714
- id: `msg-${i}`,
715
- author: { role },
716
- content: { content_type: 'text', parts: [`Message number ${i} from ${role} about various topics`] },
717
- },
718
- parent: i === 0 ? 'root' : `msg-${i - 1}`,
719
- children: i < 44 ? [`msg-${i + 1}`] : [],
720
- };
721
- }
722
-
723
- const conversations = [{ title: 'Long Conversation', mapping }];
724
-
725
- const adapter = new ChatGPTAdapter();
726
- const result = await adapter.parse({ content: JSON.stringify(conversations) });
727
-
728
- assert(result.chunks.length === 3, `ChatGPT: 45 messages -> 3 chunks (got ${result.chunks.length})`);
729
- assert(result.chunks[0].messages.length === 20, `ChatGPT: first chunk has 20 messages (got ${result.chunks[0].messages.length})`);
730
- assert(result.chunks[1].messages.length === 20, `ChatGPT: second chunk has 20 messages (got ${result.chunks[1].messages.length})`);
731
- assert(result.chunks[2].messages.length === 5, `ChatGPT: third chunk has 5 messages (got ${result.chunks[2].messages.length})`);
732
- assert(result.chunks[0].title.includes('part 1/3'), `ChatGPT: first chunk title has part indicator (got: ${result.chunks[0].title})`);
733
- assert(result.chunks[2].title.includes('part 3/3'), `ChatGPT: last chunk title has part indicator (got: ${result.chunks[2].title})`);
734
- assert(result.totalMessages === 45, `ChatGPT: totalMessages is 45 (got ${result.totalMessages})`);
735
- }
736
-
737
- // --- handles single conversation object (not array) ---
738
- {
739
- const conv = {
740
- title: 'Single',
741
- mapping: {
742
- root: { id: 'root', message: null, parent: null, children: ['msg1'] },
743
- msg1: {
744
- id: 'msg1',
745
- message: { id: 'msg1', author: { role: 'user' }, content: { content_type: 'text', parts: ['I live in San Francisco near the park'] } },
746
- parent: 'root', children: [],
747
- },
748
- },
749
- };
750
-
751
- const adapter = new ChatGPTAdapter();
752
- const result = await adapter.parse({ content: JSON.stringify(conv) });
753
-
754
- assert(result.chunks.length === 1, 'ChatGPT: single conversation object parses into 1 chunk');
755
- assert(result.chunks[0].messages[0].text.includes('San Francisco'), 'ChatGPT: single conv message correct');
756
- }
757
-
758
- // --- handles null/non-string parts ---
759
- {
760
- const conversations = [
761
- {
762
- title: 'Null Parts',
763
- mapping: {
764
- root: { id: 'root', message: null, parent: null, children: ['msg1'] },
765
- msg1: {
766
- id: 'msg1',
767
- message: {
768
- id: 'msg1',
769
- author: { role: 'user' },
770
- content: { content_type: 'text', parts: [null, { type: 'image' }, 'I prefer dark mode in all my applications'] },
771
- },
772
- parent: 'root', children: [],
773
- },
774
- },
775
- },
776
- ];
777
-
778
- const adapter = new ChatGPTAdapter();
779
- const result = await adapter.parse({ content: JSON.stringify(conversations) });
780
-
781
- assert(result.chunks.length === 1, 'ChatGPT: handles null/non-string parts');
782
- assert(result.chunks[0].messages[0].text.includes('dark mode'), 'ChatGPT: extracted text from valid part');
783
- }
784
-
785
- // --- invalid JSON returns error ---
786
- {
787
- const adapter = new ChatGPTAdapter();
788
- const result2 = await adapter.parse({ content: '[invalid json array' });
789
- assert(result2.chunks.length === 0, 'ChatGPT: invalid JSON array yields 0 chunks');
790
- assert(result2.errors.length > 0, 'ChatGPT: invalid JSON produces error');
791
- }
792
-
793
- // --- empty input returns error ---
794
- {
795
- const adapter = new ChatGPTAdapter();
796
- const result = await adapter.parse({});
797
-
798
- assert(result.chunks.length === 0, 'ChatGPT: no input yields 0 chunks');
799
- assert(result.errors.length > 0, 'ChatGPT: no input produces error');
800
- }
801
-
802
- // --- conversation with no text messages produces no chunks ---
803
- {
804
- const conversations = [
805
- {
806
- title: 'Empty',
807
- mapping: {
808
- root: { id: 'root', message: null, parent: null, children: ['msg1'] },
809
- msg1: {
810
- id: 'msg1',
811
- message: {
812
- id: 'msg1',
813
- author: { role: 'user' },
814
- content: { content_type: 'text', parts: [null] },
815
- },
816
- parent: 'root', children: [],
817
- },
818
- },
819
- },
820
- ];
821
-
822
- const adapter = new ChatGPTAdapter();
823
- const result = await adapter.parse({ content: JSON.stringify(conversations) });
824
-
825
- assert(result.chunks.length === 0, 'ChatGPT: conversation with no text -> no chunks');
826
- }
827
-
828
- // --- multiple conversations produce multiple chunks ---
829
- {
830
- const conversations = [
831
- {
832
- title: 'Conv 1',
833
- create_time: 1700000000,
834
- mapping: {
835
- root: { id: 'root', message: null, parent: null, children: ['msg1'] },
836
- msg1: {
837
- id: 'msg1',
838
- message: { id: 'msg1', author: { role: 'user' }, content: { content_type: 'text', parts: ['First conversation message'] } },
839
- parent: 'root', children: [],
840
- },
841
- },
842
- },
843
- {
844
- title: 'Conv 2',
845
- create_time: 1700100000,
846
- mapping: {
847
- root: { id: 'root', message: null, parent: null, children: ['msg1'] },
848
- msg1: {
849
- id: 'msg1',
850
- message: { id: 'msg1', author: { role: 'user' }, content: { content_type: 'text', parts: ['Second conversation message'] } },
851
- parent: 'root', children: [],
852
- },
853
- },
854
- },
855
- ];
856
-
857
- const adapter = new ChatGPTAdapter();
858
- const result = await adapter.parse({ content: JSON.stringify(conversations) });
859
-
860
- assert(result.chunks.length === 2, `ChatGPT: 2 conversations -> 2 chunks (got ${result.chunks.length})`);
861
- assert(result.chunks[0].title === 'Conv 1', 'ChatGPT: first chunk title correct');
862
- assert(result.chunks[1].title === 'Conv 2', 'ChatGPT: second chunk title correct');
863
- }
864
-
865
- // =========================================================================
866
- // ChatGPTAdapter — memories text (returns chunks, not facts)
867
- // =========================================================================
868
-
869
- console.log('# ChatGPTAdapter — memories text (chunks)');
870
-
871
- // --- parses plain text memories into chunks ---
872
- {
873
- const memoriesText = `User prefers dark mode
874
- User works at Google as a software engineer
875
- User lives in San Francisco
876
- User likes hiking on weekends`;
877
-
878
- const adapter = new ChatGPTAdapter();
879
- const result = await adapter.parse({ content: memoriesText });
880
-
881
- assert(result.facts.length === 0, 'ChatGPT memories: no pre-extracted facts');
882
- assert(result.chunks.length === 1, `ChatGPT memories: 4 lines -> 1 chunk (got ${result.chunks.length})`);
883
- assert(result.chunks[0].messages.length === 4, `ChatGPT memories: 4 messages in chunk (got ${result.chunks[0].messages.length})`);
884
- assert(result.chunks[0].messages[0].text === 'User prefers dark mode', 'ChatGPT memories: first message text correct');
885
- assert(result.chunks[0].messages[0].role === 'user', 'ChatGPT memories: all messages are user role');
886
- assert(result.totalMessages === 4, `ChatGPT memories: totalMessages is 4 (got ${result.totalMessages})`);
887
- }
888
-
889
- // --- handles bullet points and numbered lists ---
890
- {
891
- const memoriesText = `- User prefers TypeScript
892
- * User works remotely
893
- 1. User lives in Berlin
894
- 2) User likes coffee in the morning`;
895
-
896
- const adapter = new ChatGPTAdapter();
897
- const result = await adapter.parse({ content: memoriesText });
898
-
899
- assert(result.chunks.length === 1, 'ChatGPT memories: 4 lines -> 1 chunk');
900
- assert(result.chunks[0].messages.length === 4, `ChatGPT memories: handles bullets/numbers (got ${result.chunks[0].messages.length})`);
901
- assert(!result.chunks[0].messages[0].text.startsWith('-'), 'ChatGPT memories: bullet stripped');
902
- assert(!result.chunks[0].messages[2].text.startsWith('1'), 'ChatGPT memories: number stripped');
903
- }
904
-
905
- // --- skips empty lines and header lines ---
906
- {
907
- const memoriesText = `Memories:
908
-
909
- User prefers dark mode
910
-
911
- User lives in London`;
912
-
913
- const adapter = new ChatGPTAdapter();
914
- const result = await adapter.parse({ content: memoriesText });
915
-
916
- assert(result.chunks.length === 1, 'ChatGPT memories: skips header/blank');
917
- assert(result.chunks[0].messages.length === 2, `ChatGPT memories: only 2 valid lines (got ${result.chunks[0].messages.length})`);
918
- }
919
-
920
- // --- empty text input ---
921
- {
922
- const adapter = new ChatGPTAdapter();
923
- const result = await adapter.parse({ content: '' });
924
-
925
- assert(result.chunks.length === 0, 'ChatGPT memories: empty text yields 0 chunks');
926
- }
927
-
928
- // --- large memory list chunks into multiple batches ---
929
- {
930
- const lines = Array.from({ length: 50 }, (_, i) => `User memory item number ${i + 1} about their preferences`);
931
- const memoriesText = lines.join('\n');
932
-
933
- const adapter = new ChatGPTAdapter();
934
- const result = await adapter.parse({ content: memoriesText });
935
-
936
- assert(result.chunks.length === 3, `ChatGPT memories: 50 lines -> 3 chunks (got ${result.chunks.length})`);
937
- assert(result.chunks[0].messages.length === 20, `ChatGPT memories: first chunk has 20 (got ${result.chunks[0].messages.length})`);
938
- assert(result.chunks[1].messages.length === 20, `ChatGPT memories: second chunk has 20 (got ${result.chunks[1].messages.length})`);
939
- assert(result.chunks[2].messages.length === 10, `ChatGPT memories: third chunk has 10 (got ${result.chunks[2].messages.length})`);
940
- assert(result.totalMessages === 50, `ChatGPT memories: totalMessages is 50 (got ${result.totalMessages})`);
941
- }
942
-
943
- // =========================================================================
944
- // ClaudeAdapter (returns chunks, not facts)
945
- // =========================================================================
946
-
947
- console.log('# ClaudeAdapter (chunks)');
948
-
949
- // --- parses plain text memories into chunks ---
950
- {
951
- const memoriesText = `User prefers functional programming
952
- User works at a startup in Berlin
953
- User decided to use Rust for the backend
954
- User wants to learn machine learning`;
955
-
956
- const adapter = new ClaudeAdapter();
957
- const result = await adapter.parse({ content: memoriesText });
958
-
959
- assert(result.facts.length === 0, 'Claude: no pre-extracted facts (uses chunks)');
960
- assert(result.chunks.length === 1, `Claude: 4 memories -> 1 chunk (got ${result.chunks.length})`);
961
- assert(result.chunks[0].messages.length === 4, `Claude: 4 messages in chunk (got ${result.chunks[0].messages.length})`);
962
- assert(result.chunks[0].messages[0].role === 'user', 'Claude: all messages are user role');
963
- assert(result.chunks[0].messages[0].text === 'User prefers functional programming', 'Claude: first message text correct');
964
- assert(result.totalMessages === 4, `Claude: totalMessages is 4 (got ${result.totalMessages})`);
965
- }
966
-
967
- // --- handles date prefixes (strips them from text) ---
968
- {
969
- const memoriesText = `[2026-03-15] - User prefers dark mode
970
- [2026-03-10] - User works at Google
971
- No date prefix here but still a memory`;
972
-
973
- const adapter = new ClaudeAdapter();
974
- const result = await adapter.parse({ content: memoriesText });
975
-
976
- assert(result.chunks.length === 1, 'Claude: parsed into 1 chunk');
977
- assert(result.chunks[0].messages.length === 3, `Claude: 3 messages (got ${result.chunks[0].messages.length})`);
978
- assert(!result.chunks[0].messages[0].text.includes('[2026'), 'Claude: date prefix stripped from text');
979
- assert(result.chunks[0].messages[0].text === 'User prefers dark mode', 'Claude: cleaned text correct');
980
- assert(result.chunks[0].timestamp === '2026-03-15', 'Claude: chunk timestamp from first dated entry');
981
- }
982
-
983
- // --- handles bullet points and numbered lists ---
984
- {
985
- const memoriesText = `- User prefers TypeScript
986
- * User works remotely from home
987
- 1. User lives in Lisbon, Portugal
988
- 2) User likes exploring new restaurants`;
989
-
990
- const adapter = new ClaudeAdapter();
991
- const result = await adapter.parse({ content: memoriesText });
992
-
993
- assert(result.chunks.length === 1, 'Claude: 4 lines -> 1 chunk');
994
- assert(result.chunks[0].messages.length === 4, `Claude: handles bullets/numbers (got ${result.chunks[0].messages.length})`);
995
- assert(!result.chunks[0].messages[0].text.startsWith('-'), 'Claude: bullet stripped');
996
- assert(!result.chunks[0].messages[2].text.startsWith('1'), 'Claude: number stripped');
997
- }
998
-
999
- // --- skips empty lines and header lines ---
1000
- {
1001
- const memoriesText = `Claude Memories:
1002
-
1003
- User prefers dark mode in editors
1004
-
1005
- User lives in Tokyo`;
1006
-
1007
- const adapter = new ClaudeAdapter();
1008
- const result = await adapter.parse({ content: memoriesText });
1009
-
1010
- assert(result.chunks.length === 1, 'Claude: skips header/blank');
1011
- assert(result.chunks[0].messages.length === 2, `Claude: only 2 valid lines (got ${result.chunks[0].messages.length})`);
1012
- }
1013
-
1014
- // --- empty input returns error ---
1015
- {
1016
- const adapter = new ClaudeAdapter();
1017
- const result = await adapter.parse({});
1018
-
1019
- assert(result.chunks.length === 0, 'Claude: no input yields 0 chunks');
1020
- assert(result.errors.length > 0, 'Claude: no input produces error');
1021
- }
1022
-
1023
- // --- empty text yields 0 chunks ---
1024
- {
1025
- const adapter = new ClaudeAdapter();
1026
- const result = await adapter.parse({ content: '' });
1027
-
1028
- assert(result.chunks.length === 0, 'Claude: empty text yields 0 chunks');
1029
- }
1030
-
1031
- // --- skips very short memories ---
1032
- {
1033
- const memoriesText = `ok
1034
- ab
1035
- User prefers TypeScript over JavaScript`;
1036
-
1037
- const adapter = new ClaudeAdapter();
1038
- const result = await adapter.parse({ content: memoriesText });
1039
-
1040
- // "ok" and "ab" are < 3 chars after validation
1041
- assert(result.chunks.length === 1, 'Claude: has 1 chunk');
1042
- assert(result.chunks[0].messages.length === 1, `Claude: skips short memories (got ${result.chunks[0].messages.length})`);
1043
- assert(result.chunks[0].messages[0].text.includes('TypeScript'), 'Claude: valid memory kept');
1044
- }
1045
-
1046
- // --- large memory list chunks correctly ---
1047
- {
1048
- const lines = Array.from({ length: 25 }, (_, i) => `Claude memory item number ${i + 1} about their workflow`);
1049
- const memoriesText = lines.join('\n');
1050
-
1051
- const adapter = new ClaudeAdapter();
1052
- const result = await adapter.parse({ content: memoriesText });
1053
-
1054
- assert(result.chunks.length === 2, `Claude: 25 lines -> 2 chunks (got ${result.chunks.length})`);
1055
- assert(result.chunks[0].messages.length === 20, `Claude: first chunk has 20 (got ${result.chunks[0].messages.length})`);
1056
- assert(result.chunks[1].messages.length === 5, `Claude: second chunk has 5 (got ${result.chunks[1].messages.length})`);
1057
- assert(result.totalMessages === 25, `Claude: totalMessages is 25 (got ${result.totalMessages})`);
1058
- }
1059
-
1060
- // =========================================================================
1061
- // getAdapter factory
1062
- // =========================================================================
1063
-
1064
- console.log('# getAdapter factory');
1065
-
1066
- // --- valid sources ---
1067
- {
1068
- const mem0 = getAdapter('mem0');
1069
- assert(mem0 instanceof Mem0Adapter, 'getAdapter("mem0") returns Mem0Adapter');
1070
-
1071
- const mcp = getAdapter('mcp-memory');
1072
- assert(mcp instanceof MCPMemoryAdapter, 'getAdapter("mcp-memory") returns MCPMemoryAdapter');
1073
-
1074
- const chatgpt = getAdapter('chatgpt');
1075
- assert(chatgpt instanceof ChatGPTAdapter, 'getAdapter("chatgpt") returns ChatGPTAdapter');
1076
-
1077
- const claude = getAdapter('claude');
1078
- assert(claude instanceof ClaudeAdapter, 'getAdapter("claude") returns ClaudeAdapter');
1079
- }
1080
-
1081
- // --- unknown source throws ---
1082
- {
1083
- assertThrows(
1084
- () => getAdapter('nonexistent' as ImportSource),
1085
- 'getAdapter throws on unknown source',
1086
- );
1087
- }
1088
-
1089
- // --- error message lists valid sources ---
1090
- {
1091
- try {
1092
- getAdapter('bogus' as ImportSource);
1093
- assert(false, 'getAdapter should throw for bogus source');
1094
- } catch (e: unknown) {
1095
- const msg = (e as Error).message;
1096
- assert(msg.includes('mem0'), 'getAdapter error lists "mem0" as valid source');
1097
- assert(msg.includes('mcp-memory'), 'getAdapter error lists "mcp-memory" as valid source');
1098
- assert(msg.includes('chatgpt'), 'getAdapter error lists "chatgpt" as valid source');
1099
- assert(msg.includes('claude'), 'getAdapter error lists "claude" as valid source');
1100
- }
1101
- }
1102
-
1103
- // =========================================================================
1104
- // Summary
1105
- // =========================================================================
1106
-
1107
- console.log(`\n1..${testNum}`);
1108
- console.log(`# pass: ${passed}`);
1109
- console.log(`# fail: ${failed}`);
1110
-
1111
- if (failed > 0) {
1112
- console.log('\nFAILED');
1113
- process.exit(1);
1114
- } else {
1115
- console.log('\nALL TESTS PASSED');
1116
- process.exit(0);
1117
- }
1118
- }
1119
-
1120
- runTests().catch((err) => {
1121
- console.error('Test runner failed:', err);
1122
- process.exit(1);
1123
- });