bluera-knowledge 0.11.17 → 0.11.19

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.
Files changed (42) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +48 -0
  3. package/dist/{chunk-ZDEO4WJT.js → chunk-GOAOBPOA.js} +22 -70
  4. package/dist/chunk-GOAOBPOA.js.map +1 -0
  5. package/dist/{chunk-6FHWC36B.js → chunk-HRQD3MPH.js} +8 -6
  6. package/dist/chunk-HRQD3MPH.js.map +1 -0
  7. package/dist/{chunk-ZZNABJMQ.js → chunk-QEHSDQTL.js} +79 -13
  8. package/dist/chunk-QEHSDQTL.js.map +1 -0
  9. package/dist/{chunk-5NUI6JL6.js → chunk-VP4VZULK.js} +2 -2
  10. package/dist/index.js +36 -18
  11. package/dist/index.js.map +1 -1
  12. package/dist/mcp/server.js +3 -3
  13. package/dist/watch.service-OPLKIDFQ.js +7 -0
  14. package/dist/workers/background-worker-cli.js +3 -3
  15. package/package.json +1 -1
  16. package/src/cli/commands/crawl.ts +1 -1
  17. package/src/cli/commands/index-cmd.test.ts +14 -4
  18. package/src/cli/commands/index-cmd.ts +11 -4
  19. package/src/cli/commands/store.test.ts +211 -18
  20. package/src/cli/commands/store.ts +26 -8
  21. package/src/crawl/article-converter.test.ts +30 -61
  22. package/src/crawl/article-converter.ts +2 -8
  23. package/src/crawl/bridge.test.ts +14 -0
  24. package/src/crawl/bridge.ts +17 -5
  25. package/src/crawl/intelligent-crawler.test.ts +65 -76
  26. package/src/crawl/intelligent-crawler.ts +33 -69
  27. package/src/plugin/git-clone.test.ts +44 -0
  28. package/src/plugin/git-clone.ts +4 -0
  29. package/src/services/code-unit.service.test.ts +59 -6
  30. package/src/services/code-unit.service.ts +47 -2
  31. package/src/services/index.ts +19 -3
  32. package/src/services/job.service.test.ts +10 -7
  33. package/src/services/job.service.ts +12 -6
  34. package/src/services/services.test.ts +19 -6
  35. package/src/services/watch.service.test.ts +80 -56
  36. package/src/services/watch.service.ts +9 -6
  37. package/dist/chunk-6FHWC36B.js.map +0 -1
  38. package/dist/chunk-ZDEO4WJT.js.map +0 -1
  39. package/dist/chunk-ZZNABJMQ.js.map +0 -1
  40. package/dist/watch.service-BJV3TI3F.js +0 -7
  41. /package/dist/{chunk-5NUI6JL6.js.map → chunk-VP4VZULK.js.map} +0 -0
  42. /package/dist/{watch.service-BJV3TI3F.js.map → watch.service-OPLKIDFQ.js.map} +0 -0
@@ -141,17 +141,7 @@ export class IntelligentCrawler extends EventEmitter {
141
141
  ): AsyncIterable<CrawlResult> {
142
142
  // Check if Claude CLI is available before attempting intelligent mode
143
143
  if (!ClaudeClient.isAvailable()) {
144
- const fallbackProgress: CrawlProgress = {
145
- type: 'error',
146
- pagesVisited: 0,
147
- totalPages: maxPages,
148
- message:
149
- 'Claude CLI not found, using simple crawl mode (install Claude Code for intelligent crawling)',
150
- error: new Error('Claude CLI not available'),
151
- };
152
- this.emit('progress', fallbackProgress);
153
- yield* this.crawlSimple(seedUrl, extractInstruction, maxPages, useHeadless);
154
- return;
144
+ throw new Error('Claude CLI not available: install Claude Code for intelligent crawling');
155
145
  }
156
146
 
157
147
  let strategy: CrawlStrategy;
@@ -180,18 +170,8 @@ export class IntelligentCrawler extends EventEmitter {
180
170
  };
181
171
  this.emit('progress', strategyCompleteProgress);
182
172
  } catch (error) {
183
- // Fallback to simple mode if Claude fails
184
- const errorProgress: CrawlProgress = {
185
- type: 'error',
186
- pagesVisited: 0,
187
- totalPages: maxPages,
188
- message: 'Claude crawl strategy failed, falling back to simple mode',
189
- error: error instanceof Error ? error : new Error(String(error)),
190
- };
191
- this.emit('progress', errorProgress);
192
-
193
- yield* this.crawlSimple(seedUrl, extractInstruction, maxPages);
194
- return;
173
+ // Re-throw strategy errors - do not fall back silently
174
+ throw error instanceof Error ? error : new Error(String(error));
195
175
  }
196
176
 
197
177
  // Step 3: Crawl each URL from Claude's strategy
@@ -288,12 +268,25 @@ export class IntelligentCrawler extends EventEmitter {
288
268
  }
289
269
  }
290
270
  } catch (error) {
271
+ const errorObj = error instanceof Error ? error : new Error(String(error));
272
+
273
+ // Re-throw non-recoverable errors (extraction failures, Claude CLI not available, headless failures)
274
+ // These represent failures in user-requested functionality that should not be silently skipped
275
+ if (
276
+ errorObj.message.includes('Extraction failed') ||
277
+ errorObj.message.includes('Claude CLI not available') ||
278
+ errorObj.message.includes('Headless fetch failed')
279
+ ) {
280
+ throw errorObj;
281
+ }
282
+
283
+ // For recoverable errors (page fetch failures), emit progress and continue
291
284
  const simpleErrorProgress: CrawlProgress = {
292
285
  type: 'error',
293
286
  pagesVisited,
294
287
  totalPages: maxPages,
295
288
  currentUrl: current.url,
296
- error: error instanceof Error ? error : new Error(String(error)),
289
+ error: errorObj,
297
290
  };
298
291
  this.emit('progress', simpleErrorProgress);
299
292
  }
@@ -324,13 +317,9 @@ export class IntelligentCrawler extends EventEmitter {
324
317
  const html = await this.fetchHtml(url, useHeadless);
325
318
 
326
319
  // Convert to clean markdown using slurp-ai techniques
320
+ // Note: convertHtmlToMarkdown throws on errors, no need to check success
327
321
  const conversion = await convertHtmlToMarkdown(html, url);
328
322
 
329
- if (!conversion.success) {
330
- logger.error({ url, error: conversion.error }, 'HTML to markdown conversion failed');
331
- throw new Error(`Failed to convert HTML: ${conversion.error ?? 'Unknown error'}`);
332
- }
333
-
334
323
  logger.debug(
335
324
  {
336
325
  url,
@@ -344,44 +333,20 @@ export class IntelligentCrawler extends EventEmitter {
344
333
 
345
334
  // Optional: Extract specific information using Claude
346
335
  if (extractInstruction !== undefined && extractInstruction !== '') {
347
- // Skip extraction if Claude CLI isn't available
336
+ // Throw if extraction requested but Claude CLI isn't available
348
337
  if (!ClaudeClient.isAvailable()) {
349
- const skipProgress: CrawlProgress = {
350
- type: 'error',
351
- pagesVisited,
352
- totalPages: 0,
353
- currentUrl: url,
354
- message: 'Skipping extraction (Claude CLI not available), storing raw markdown',
355
- error: new Error('Claude CLI not available'),
356
- };
357
- this.emit('progress', skipProgress);
358
- } else {
359
- try {
360
- const extractionProgress: CrawlProgress = {
361
- type: 'extraction',
362
- pagesVisited,
363
- totalPages: 0,
364
- currentUrl: url,
365
- };
366
- this.emit('progress', extractionProgress);
367
-
368
- extracted = await this.claudeClient.extractContent(
369
- conversion.markdown,
370
- extractInstruction
371
- );
372
- } catch (error) {
373
- // If extraction fails, just store raw markdown
374
- const extractionErrorProgress: CrawlProgress = {
375
- type: 'error',
376
- pagesVisited,
377
- totalPages: 0,
378
- currentUrl: url,
379
- message: 'Extraction failed, storing raw markdown',
380
- error: error instanceof Error ? error : new Error(String(error)),
381
- };
382
- this.emit('progress', extractionErrorProgress);
383
- }
338
+ throw new Error('Claude CLI not available: install Claude Code for extraction');
384
339
  }
340
+
341
+ const extractionProgress: CrawlProgress = {
342
+ type: 'extraction',
343
+ pagesVisited,
344
+ totalPages: 0,
345
+ currentUrl: url,
346
+ };
347
+ this.emit('progress', extractionProgress);
348
+
349
+ extracted = await this.claudeClient.extractContent(conversion.markdown, extractInstruction);
385
350
  }
386
351
 
387
352
  return {
@@ -414,10 +379,9 @@ export class IntelligentCrawler extends EventEmitter {
414
379
  );
415
380
  return result.html;
416
381
  } catch (error) {
417
- // Fallback to axios if headless fails
418
- logger.warn(
419
- { url, error: error instanceof Error ? error.message : String(error) },
420
- 'Headless fetch failed, falling back to axios'
382
+ // Wrap with distinctive message so crawlSimple knows not to recover
383
+ throw new Error(
384
+ `Headless fetch failed: ${error instanceof Error ? error.message : String(error)}`
421
385
  );
422
386
  }
423
387
  }
@@ -266,6 +266,50 @@ describe('GitClone - cloneRepository', () => {
266
266
  expect(result.error.message).toContain('not found');
267
267
  }
268
268
  });
269
+
270
+ it('handles spawn error when git is not installed', async () => {
271
+ const mockProcess = new EventEmitter() as ChildProcess;
272
+ mockProcess.stderr = new EventEmitter() as any;
273
+
274
+ mockSpawn.mockImplementation(() => {
275
+ setImmediate(() => {
276
+ mockProcess.emit('error', new Error('spawn git ENOENT'));
277
+ });
278
+ return mockProcess;
279
+ });
280
+
281
+ const result = await cloneRepository({
282
+ url: 'https://github.com/user/repo.git',
283
+ targetDir: join(tempDir, 'repo'),
284
+ });
285
+
286
+ expect(result.success).toBe(false);
287
+ if (!result.success) {
288
+ expect(result.error.message).toContain('spawn git ENOENT');
289
+ }
290
+ });
291
+
292
+ it('handles spawn error with permission denied', async () => {
293
+ const mockProcess = new EventEmitter() as ChildProcess;
294
+ mockProcess.stderr = new EventEmitter() as any;
295
+
296
+ mockSpawn.mockImplementation(() => {
297
+ setImmediate(() => {
298
+ mockProcess.emit('error', new Error('spawn git EACCES'));
299
+ });
300
+ return mockProcess;
301
+ });
302
+
303
+ const result = await cloneRepository({
304
+ url: 'https://github.com/user/repo.git',
305
+ targetDir: join(tempDir, 'repo'),
306
+ });
307
+
308
+ expect(result.success).toBe(false);
309
+ if (!result.success) {
310
+ expect(result.error.message).toContain('EACCES');
311
+ }
312
+ });
269
313
  });
270
314
 
271
315
  describe('GitClone - isGitUrl', () => {
@@ -29,6 +29,10 @@ export async function cloneRepository(options: CloneOptions): Promise<Result<str
29
29
  stderr += data.toString();
30
30
  });
31
31
 
32
+ git.on('error', (error: Error) => {
33
+ resolve(err(error));
34
+ });
35
+
32
36
  git.on('close', (code: number | null) => {
33
37
  if (code === 0) {
34
38
  resolve(ok(targetDir));
@@ -345,7 +345,7 @@ function testLonger() {
345
345
  expect(result?.fullContent).not.toContain('testLonger');
346
346
  });
347
347
 
348
- it('does NOT extract interface definitions (known limitation)', () => {
348
+ it('extracts interface definitions', () => {
349
349
  const code = `
350
350
  interface User {
351
351
  name: string;
@@ -355,11 +355,31 @@ interface User {
355
355
 
356
356
  const result = service.extractCodeUnit(code, 'User', 'typescript');
357
357
 
358
- // Interfaces are not currently supported
359
- expect(result).toBeUndefined();
358
+ expect(result).toBeDefined();
359
+ expect(result?.type).toBe('interface');
360
+ expect(result?.name).toBe('User');
361
+ expect(result?.startLine).toBe(1);
362
+ expect(result?.endLine).toBe(4);
363
+ expect(result?.fullContent).toContain('name: string');
364
+ expect(result?.fullContent).toContain('age: number');
365
+ });
366
+
367
+ it('extracts exported interface definitions', () => {
368
+ const code = `
369
+ export interface Config {
370
+ host: string;
371
+ port: number;
372
+ }
373
+ `.trim();
374
+
375
+ const result = service.extractCodeUnit(code, 'Config', 'typescript');
376
+
377
+ expect(result).toBeDefined();
378
+ expect(result?.type).toBe('interface');
379
+ expect(result?.signature).toBe('interface Config');
360
380
  });
361
381
 
362
- it('does NOT extract type definitions (known limitation)', () => {
382
+ it('extracts type definitions', () => {
363
383
  const code = `
364
384
  type Config = {
365
385
  host: string;
@@ -369,8 +389,41 @@ type Config = {
369
389
 
370
390
  const result = service.extractCodeUnit(code, 'Config', 'typescript');
371
391
 
372
- // Type definitions are not currently supported
373
- expect(result).toBeUndefined();
392
+ expect(result).toBeDefined();
393
+ expect(result?.type).toBe('type');
394
+ expect(result?.name).toBe('Config');
395
+ expect(result?.startLine).toBe(1);
396
+ expect(result?.endLine).toBe(4);
397
+ expect(result?.fullContent).toContain('host: string');
398
+ });
399
+
400
+ it('extracts exported type definitions', () => {
401
+ const code = `
402
+ export type Result<T> = {
403
+ success: boolean;
404
+ data: T;
405
+ };
406
+ `.trim();
407
+
408
+ const result = service.extractCodeUnit(code, 'Result', 'typescript');
409
+
410
+ expect(result).toBeDefined();
411
+ expect(result?.type).toBe('type');
412
+ expect(result?.signature).toContain('type Result');
413
+ });
414
+
415
+ it('extracts type alias (non-object)', () => {
416
+ const code = `
417
+ type UserId = string;
418
+ `.trim();
419
+
420
+ const result = service.extractCodeUnit(code, 'UserId', 'typescript');
421
+
422
+ expect(result).toBeDefined();
423
+ expect(result?.type).toBe('type');
424
+ expect(result?.name).toBe('UserId');
425
+ expect(result?.startLine).toBe(1);
426
+ expect(result?.endLine).toBe(1);
374
427
  });
375
428
 
376
429
  it('handles function with generics in signature', () => {
@@ -16,8 +16,6 @@ export class CodeUnitService {
16
16
  let startLine = -1;
17
17
  let type: CodeUnit['type'] = 'function';
18
18
 
19
- // NOTE: Now supports function declarations, class declarations, and arrow functions (const/let/var).
20
- // Does not handle interfaces or type definitions yet.
21
19
  for (let i = 0; i < lines.length; i++) {
22
20
  const line = lines[i] ?? '';
23
21
 
@@ -33,6 +31,20 @@ export class CodeUnitService {
33
31
  break;
34
32
  }
35
33
 
34
+ // Check for interface declarations
35
+ if (line.match(new RegExp(`interface\\s+${symbolName}(?:\\s|{|<)`))) {
36
+ startLine = i + 1;
37
+ type = 'interface';
38
+ break;
39
+ }
40
+
41
+ // Check for type declarations
42
+ if (line.match(new RegExp(`type\\s+${symbolName}(?:\\s|=|<)`))) {
43
+ startLine = i + 1;
44
+ type = 'type';
45
+ break;
46
+ }
47
+
36
48
  // Check for arrow functions: const/let/var name = ...
37
49
  if (line.match(new RegExp(`(?:const|let|var)\\s+${symbolName}\\s*=`))) {
38
50
  startLine = i + 1;
@@ -48,6 +60,26 @@ export class CodeUnitService {
48
60
  let braceCount = 0;
49
61
  let foundFirstBrace = false;
50
62
 
63
+ // For type aliases without braces (e.g., "type UserId = string;"), find semicolon
64
+ if (type === 'type') {
65
+ const firstLine = lines[startLine - 1] ?? '';
66
+ if (!firstLine.includes('{') && firstLine.includes(';')) {
67
+ // Single-line type alias
68
+ endLine = startLine;
69
+ const fullContent = firstLine;
70
+ const signature = this.extractSignature(firstLine, symbolName, type);
71
+ return {
72
+ type,
73
+ name: symbolName,
74
+ signature,
75
+ fullContent,
76
+ startLine,
77
+ endLine,
78
+ language,
79
+ };
80
+ }
81
+ }
82
+
51
83
  // State machine for tracking context
52
84
  let inSingleQuote = false;
53
85
  let inDoubleQuote = false;
@@ -168,6 +200,19 @@ export class CodeUnitService {
168
200
  return `class ${name}`;
169
201
  }
170
202
 
203
+ if (type === 'interface') {
204
+ return `interface ${name}`;
205
+ }
206
+
207
+ if (type === 'type') {
208
+ // For type aliases, include generics if present
209
+ const typeMatch = sig.match(new RegExp(`type\\s+(${name}(?:<[^>]+>)?)\\s*=`));
210
+ if (typeMatch?.[1] !== undefined && typeMatch[1].length > 0) {
211
+ return `type ${typeMatch[1]}`;
212
+ }
213
+ return `type ${name}`;
214
+ }
215
+
171
216
  if (type === 'const') {
172
217
  // For arrow functions, extract the variable declaration part
173
218
  // Example: const myFunc = (param: string): void => ...
@@ -73,22 +73,38 @@ export async function createServices(
73
73
  /**
74
74
  * Cleanly shut down all services, stopping background processes.
75
75
  * Call this after CLI commands complete to allow the process to exit.
76
+ * Attempts all cleanup operations and throws if any fail.
76
77
  */
77
78
  export async function destroyServices(services: ServiceContainer): Promise<void> {
78
79
  logger.info('Shutting down services');
80
+ const errors: Error[] = [];
81
+
82
+ // Use async close to allow native threads time to cleanup
79
83
  try {
80
- // Use async close to allow native threads time to cleanup
81
84
  await services.lance.closeAsync();
82
85
  } catch (e) {
83
- logger.error({ error: e }, 'Error closing LanceStore');
86
+ const error = e instanceof Error ? e : new Error(String(e));
87
+ logger.error({ error }, 'Error closing LanceStore');
88
+ errors.push(error);
84
89
  }
90
+
85
91
  try {
86
92
  await services.pythonBridge.stop();
87
93
  } catch (e) {
88
- logger.error({ error: e }, 'Error stopping Python bridge');
94
+ const error = e instanceof Error ? e : new Error(String(e));
95
+ logger.error({ error }, 'Error stopping Python bridge');
96
+ errors.push(error);
89
97
  }
98
+
90
99
  // Additional delay to allow native threads (LanceDB, tree-sitter, transformers)
91
100
  // to fully complete their cleanup before process exit
92
101
  await new Promise((resolve) => setTimeout(resolve, 100));
93
102
  await shutdownLogger();
103
+
104
+ // Throw if any errors occurred during cleanup
105
+ if (errors.length === 1 && errors[0] !== undefined) {
106
+ throw new Error(`Service shutdown failed: ${errors[0].message}`, { cause: errors[0] });
107
+ } else if (errors.length > 1) {
108
+ throw new AggregateError(errors, 'Multiple errors during service shutdown');
109
+ }
94
110
  }
@@ -111,6 +111,14 @@ describe('JobService', () => {
111
111
  const job = jobService.getJob('non-existent-job');
112
112
  expect(job).toBeNull();
113
113
  });
114
+
115
+ it('should throw on corrupted job file', () => {
116
+ // Create a corrupted job file
117
+ const jobFile = join(tempDir, 'jobs', 'corrupted-job.json');
118
+ writeFileSync(jobFile, 'invalid json{{{', 'utf-8');
119
+
120
+ expect(() => jobService.getJob('corrupted-job')).toThrow('Failed to read job');
121
+ });
114
122
  });
115
123
 
116
124
  describe('listJobs', () => {
@@ -138,17 +146,12 @@ describe('JobService', () => {
138
146
  expect(activeJobs.length).toBe(2);
139
147
  });
140
148
 
141
- it('should skip corrupted job files', () => {
142
- const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
143
-
149
+ it('should throw on corrupted job files', () => {
144
150
  // Create a corrupted job file
145
151
  const jobFile = join(tempDir, 'jobs', 'corrupted.json');
146
152
  writeFileSync(jobFile, 'invalid json{{{', 'utf-8');
147
153
 
148
- const jobs = jobService.listJobs();
149
- expect(jobs.length).toBe(3); // Should skip corrupted file
150
-
151
- consoleErrorSpy.mockRestore();
154
+ expect(() => jobService.listJobs()).toThrow('Failed to read job file');
152
155
  });
153
156
 
154
157
  it('should skip non-JSON files', () => {
@@ -89,8 +89,9 @@ export class JobService {
89
89
  // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
90
90
  return JSON.parse(content) as Job;
91
91
  } catch (error) {
92
- console.error(`Error reading job ${jobId}:`, error);
93
- return null;
92
+ throw new Error(
93
+ `Failed to read job ${jobId}: ${error instanceof Error ? error.message : String(error)}`
94
+ );
94
95
  }
95
96
  }
96
97
 
@@ -124,7 +125,9 @@ export class JobService {
124
125
  jobs.push(job);
125
126
  }
126
127
  } catch (error) {
127
- console.error(`Error reading job file ${file}:`, error);
128
+ throw new Error(
129
+ `Failed to read job file ${file}: ${error instanceof Error ? error.message : String(error)}`
130
+ );
128
131
  }
129
132
  }
130
133
 
@@ -209,7 +212,9 @@ export class JobService {
209
212
  fs.unlinkSync(jobFile);
210
213
  cleaned++;
211
214
  } catch (error) {
212
- console.error(`Error deleting job file ${job.id}:`, error);
215
+ throw new Error(
216
+ `Failed to delete job file ${job.id}: ${error instanceof Error ? error.message : String(error)}`
217
+ );
213
218
  }
214
219
  }
215
220
  }
@@ -231,8 +236,9 @@ export class JobService {
231
236
  fs.unlinkSync(jobFile);
232
237
  return true;
233
238
  } catch (error) {
234
- console.error(`Error deleting job ${jobId}:`, error);
235
- return false;
239
+ throw new Error(
240
+ `Failed to delete job ${jobId}: ${error instanceof Error ? error.message : String(error)}`
241
+ );
236
242
  }
237
243
  }
238
244
 
@@ -30,11 +30,12 @@ describe('destroyServices', () => {
30
30
  expect(mockPythonBridge.stop).toHaveBeenCalledTimes(1);
31
31
  });
32
32
 
33
- it('handles stop errors gracefully', async () => {
33
+ it('throws on stop errors', async () => {
34
34
  mockPythonBridge.stop.mockRejectedValue(new Error('stop failed'));
35
35
 
36
- // destroyServices catches and logs errors instead of propagating (Bug #2 fix)
37
- await expect(destroyServices(mockServices)).resolves.not.toThrow();
36
+ await expect(destroyServices(mockServices)).rejects.toThrow(
37
+ 'Service shutdown failed: stop failed'
38
+ );
38
39
  });
39
40
 
40
41
  it('is idempotent - multiple calls work correctly', async () => {
@@ -52,11 +53,23 @@ describe('destroyServices', () => {
52
53
  expect(mockLance.close).not.toHaveBeenCalled();
53
54
  });
54
55
 
55
- it('handles LanceStore closeAsync errors gracefully', async () => {
56
+ it('throws on LanceStore closeAsync errors', async () => {
56
57
  mockLance.closeAsync.mockRejectedValue(new Error('closeAsync failed'));
57
58
 
58
- // Should not throw even if closeAsync fails
59
- await expect(destroyServices(mockServices)).resolves.not.toThrow();
59
+ await expect(destroyServices(mockServices)).rejects.toThrow(
60
+ 'Service shutdown failed: closeAsync failed'
61
+ );
62
+ });
63
+
64
+ it('attempts all cleanup even if first fails, then throws aggregate', async () => {
65
+ mockLance.closeAsync.mockRejectedValue(new Error('lance failed'));
66
+ mockPythonBridge.stop.mockRejectedValue(new Error('bridge failed'));
67
+
68
+ await expect(destroyServices(mockServices)).rejects.toThrow();
69
+
70
+ // Both should have been called even though first failed
71
+ expect(mockLance.closeAsync).toHaveBeenCalledTimes(1);
72
+ expect(mockPythonBridge.stop).toHaveBeenCalledTimes(1);
60
73
  });
61
74
 
62
75
  it('waits for LanceStore async cleanup before returning', async () => {