@veraxhq/verax 0.2.0 → 0.2.1

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 (89) hide show
  1. package/package.json +14 -4
  2. package/src/cli/commands/default.js +244 -86
  3. package/src/cli/commands/doctor.js +36 -4
  4. package/src/cli/commands/run.js +253 -69
  5. package/src/cli/entry.js +5 -5
  6. package/src/cli/util/detection-engine.js +4 -3
  7. package/src/cli/util/events.js +76 -0
  8. package/src/cli/util/expectation-extractor.js +11 -1
  9. package/src/cli/util/findings-writer.js +1 -0
  10. package/src/cli/util/observation-engine.js +69 -23
  11. package/src/cli/util/paths.js +3 -2
  12. package/src/cli/util/project-discovery.js +20 -0
  13. package/src/cli/util/redact.js +2 -2
  14. package/src/cli/util/runtime-budget.js +147 -0
  15. package/src/cli/util/summary-writer.js +12 -1
  16. package/src/types/global.d.ts +28 -0
  17. package/src/types/ts-ast.d.ts +24 -0
  18. package/src/verax/cli/doctor.js +2 -2
  19. package/src/verax/cli/init.js +1 -1
  20. package/src/verax/cli/url-safety.js +12 -2
  21. package/src/verax/cli/wizard.js +13 -2
  22. package/src/verax/core/budget-engine.js +1 -1
  23. package/src/verax/core/decision-snapshot.js +2 -2
  24. package/src/verax/core/determinism-model.js +35 -6
  25. package/src/verax/core/incremental-store.js +15 -7
  26. package/src/verax/core/replay-validator.js +4 -4
  27. package/src/verax/core/replay.js +1 -1
  28. package/src/verax/core/silence-impact.js +1 -1
  29. package/src/verax/core/silence-model.js +9 -7
  30. package/src/verax/detect/comparison.js +8 -3
  31. package/src/verax/detect/confidence-engine.js +17 -17
  32. package/src/verax/detect/detection-engine.js +1 -1
  33. package/src/verax/detect/evidence-index.js +15 -65
  34. package/src/verax/detect/expectation-model.js +54 -3
  35. package/src/verax/detect/explanation-helpers.js +1 -1
  36. package/src/verax/detect/finding-detector.js +2 -2
  37. package/src/verax/detect/findings-writer.js +9 -16
  38. package/src/verax/detect/flow-detector.js +4 -4
  39. package/src/verax/detect/index.js +37 -11
  40. package/src/verax/detect/interactive-findings.js +3 -4
  41. package/src/verax/detect/signal-mapper.js +2 -2
  42. package/src/verax/detect/skip-classifier.js +4 -4
  43. package/src/verax/detect/verdict-engine.js +4 -6
  44. package/src/verax/flow/flow-engine.js +3 -2
  45. package/src/verax/flow/flow-spec.js +1 -2
  46. package/src/verax/index.js +15 -3
  47. package/src/verax/intel/effect-detector.js +1 -1
  48. package/src/verax/intel/index.js +2 -2
  49. package/src/verax/intel/route-extractor.js +3 -3
  50. package/src/verax/intel/vue-navigation-extractor.js +81 -18
  51. package/src/verax/intel/vue-router-extractor.js +4 -2
  52. package/src/verax/learn/action-contract-extractor.js +3 -3
  53. package/src/verax/learn/ast-contract-extractor.js +53 -1
  54. package/src/verax/learn/index.js +36 -2
  55. package/src/verax/learn/manifest-writer.js +28 -14
  56. package/src/verax/learn/route-extractor.js +1 -1
  57. package/src/verax/learn/route-validator.js +8 -7
  58. package/src/verax/learn/state-extractor.js +1 -1
  59. package/src/verax/learn/static-extractor-navigation.js +1 -1
  60. package/src/verax/learn/static-extractor-validation.js +2 -2
  61. package/src/verax/learn/static-extractor.js +8 -7
  62. package/src/verax/learn/ts-contract-resolver.js +14 -12
  63. package/src/verax/observe/browser.js +22 -3
  64. package/src/verax/observe/console-sensor.js +2 -2
  65. package/src/verax/observe/expectation-executor.js +2 -1
  66. package/src/verax/observe/focus-sensor.js +1 -1
  67. package/src/verax/observe/human-driver.js +29 -10
  68. package/src/verax/observe/index.js +10 -7
  69. package/src/verax/observe/interaction-discovery.js +27 -15
  70. package/src/verax/observe/interaction-runner.js +6 -6
  71. package/src/verax/observe/loading-sensor.js +6 -0
  72. package/src/verax/observe/navigation-sensor.js +1 -1
  73. package/src/verax/observe/settle.js +1 -0
  74. package/src/verax/observe/state-sensor.js +8 -4
  75. package/src/verax/observe/state-ui-sensor.js +7 -1
  76. package/src/verax/observe/traces-writer.js +27 -16
  77. package/src/verax/observe/ui-signal-sensor.js +7 -0
  78. package/src/verax/scan-summary-writer.js +5 -2
  79. package/src/verax/shared/artifact-manager.js +1 -1
  80. package/src/verax/shared/budget-profiles.js +2 -2
  81. package/src/verax/shared/caching.js +1 -1
  82. package/src/verax/shared/config-loader.js +1 -2
  83. package/src/verax/shared/dynamic-route-utils.js +12 -6
  84. package/src/verax/shared/retry-policy.js +1 -6
  85. package/src/verax/shared/root-artifacts.js +1 -1
  86. package/src/verax/shared/zip-artifacts.js +1 -0
  87. package/src/verax/validate/context-validator.js +1 -1
  88. package/src/verax/observe/index.js.backup +0 -1
  89. package/src/verax/validate/context-validator.js.bak +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@veraxhq/verax",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "VERAX - Silent failure detection for websites",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -14,8 +14,11 @@
14
14
  "LICENSE"
15
15
  ],
16
16
  "scripts": {
17
- "test": "node --test",
18
- "test:pack": "node scripts/test-pack.js"
17
+ "test": "node scripts/test-runner-wrapper.js",
18
+ "test:pack": "node scripts/test-pack.js",
19
+ "verify-release": "node scripts/verify-release.js",
20
+ "lint": "eslint . --max-warnings 0",
21
+ "typecheck": "tsc -p tsconfig.json --noEmit"
19
22
  },
20
23
  "dependencies": {
21
24
  "glob": "^10.3.10",
@@ -30,6 +33,13 @@
30
33
  "devDependencies": {
31
34
  "@babel/parser": "^7.28.5",
32
35
  "@babel/traverse": "^7.28.5",
33
- "@veraxhq/verax": "file:veraxhq-verax-0.2.0.tgz"
36
+ "@reduxjs/toolkit": "^2.11.2",
37
+ "@veraxhq/verax": "file:veraxhq-verax-0.2.1.tgz",
38
+ "eslint": "^8.57.0",
39
+ "next": "^16.1.1",
40
+ "react": "^19.2.3",
41
+ "react-dom": "^19.2.3",
42
+ "react-redux": "^9.2.0",
43
+ "react-router-dom": "^7.12.0"
34
44
  }
35
45
  }
@@ -3,7 +3,7 @@ import { existsSync, readFileSync } from 'fs';
3
3
  import { fileURLToPath } from 'url';
4
4
  import { dirname } from 'path';
5
5
  import inquirer from 'inquirer';
6
- import { UsageError, DataError, CrashError } from '../util/errors.js';
6
+ import { DataError } from '../util/errors.js';
7
7
  import { generateRunId } from '../util/run-id.js';
8
8
  import { getRunPaths, ensureRunDirectories } from '../util/paths.js';
9
9
  import { atomicWriteJson, atomicWriteText } from '../util/atomic-write.js';
@@ -18,6 +18,7 @@ import { writeObserveJson } from '../util/observe-writer.js';
18
18
  import { detectFindings } from '../util/detection-engine.js';
19
19
  import { writeFindingsJson } from '../util/findings-writer.js';
20
20
  import { writeSummaryJson } from '../util/summary-writer.js';
21
+ import { computeRuntimeBudget, withTimeout } from '../util/runtime-budget.js';
21
22
 
22
23
  const __filename = fileURLToPath(import.meta.url);
23
24
  const __dirname = dirname(__filename);
@@ -72,6 +73,79 @@ export async function defaultCommand(options = {}) {
72
73
  });
73
74
  }
74
75
 
76
+ let runId = null;
77
+ /** @type {ReturnType<typeof getRunPaths> | null} */
78
+ let paths = null;
79
+ let startedAt = null;
80
+ let watchdogTimer = null;
81
+ let budget = null;
82
+ let timedOut = false;
83
+
84
+ // Graceful finalization function
85
+ const finalizeOnTimeout = async (reason) => {
86
+ if (timedOut) return; // Prevent double finalization
87
+ timedOut = true;
88
+
89
+ events.stopHeartbeat();
90
+
91
+ // TypeScript narrowing: paths is guaranteed to be non-null here due to control flow
92
+ if (paths && runId && startedAt) {
93
+ try {
94
+ const failedAt = new Date().toISOString();
95
+ atomicWriteJson(paths.runStatusJson, {
96
+ status: 'FAILED',
97
+ runId,
98
+ startedAt,
99
+ failedAt,
100
+ error: reason,
101
+ });
102
+
103
+ atomicWriteJson(paths.runMetaJson, {
104
+ veraxVersion: getVersion(),
105
+ nodeVersion: process.version,
106
+ platform: process.platform,
107
+ cwd: projectRoot,
108
+ command: 'default',
109
+ args: { url: url || null, src },
110
+ url: url || null,
111
+ src: srcPath,
112
+ startedAt,
113
+ completedAt: failedAt,
114
+ error: reason,
115
+ });
116
+
117
+ try {
118
+ writeSummaryJson(paths.summaryJson, {
119
+ runId,
120
+ status: 'FAILED',
121
+ startedAt,
122
+ completedAt: failedAt,
123
+ command: 'default',
124
+ url: url || null,
125
+ notes: `Run timed out: ${reason}`,
126
+ }, {
127
+ expectationsTotal: 0,
128
+ attempted: 0,
129
+ observed: 0,
130
+ silentFailures: 0,
131
+ coverageGaps: 0,
132
+ unproven: 0,
133
+ informational: 0,
134
+ });
135
+ } catch (summaryError) {
136
+ // Ignore summary write errors during timeout handling
137
+ }
138
+ } catch (statusError) {
139
+ // Ignore errors when writing failure status
140
+ }
141
+ }
142
+
143
+ events.emit('error', {
144
+ message: reason,
145
+ type: 'timeout',
146
+ });
147
+ };
148
+
75
149
  try {
76
150
  events.emit('phase:started', {
77
151
  phase: 'Detect Project',
@@ -231,8 +305,17 @@ export async function defaultCommand(options = {}) {
231
305
  message: 'Analyzing project structure...',
232
306
  });
233
307
 
234
- // Extract expectations
235
- const { expectations, skipped } = await extractExpectations(projectProfile, projectProfile.sourceRoot);
308
+ events.startHeartbeat('Learn', json);
309
+
310
+ let expectations, skipped;
311
+ try {
312
+ // Extract expectations
313
+ const result = await extractExpectations(projectProfile, projectProfile.sourceRoot);
314
+ expectations = result.expectations;
315
+ skipped = result.skipped;
316
+ } finally {
317
+ events.stopHeartbeat();
318
+ }
236
319
 
237
320
  if (!json) {
238
321
  console.log(`Found ${expectations.length} expectations`);
@@ -256,55 +339,109 @@ export async function defaultCommand(options = {}) {
256
339
  });
257
340
  }
258
341
 
342
+ // Compute runtime budget based on expectations count
343
+ budget = computeRuntimeBudget({
344
+ expectationsCount: expectations.length,
345
+ mode: 'default',
346
+ framework: projectProfile.framework,
347
+ fileCount: projectProfile.fileCount || expectations.length,
348
+ });
349
+
350
+ // Set up global watchdog timer
351
+ watchdogTimer = setTimeout(async () => {
352
+ await finalizeOnTimeout(`Global timeout exceeded: ${budget.totalMaxMs}ms`);
353
+ // Exit with code 0 (tool executed, just timed out)
354
+ process.exit(0);
355
+ }, budget.totalMaxMs);
356
+
357
+ // Wrap Learn phase with timeout
358
+ try {
359
+ await withTimeout(
360
+ budget.learnMaxMs,
361
+ Promise.resolve(), // Learn phase already completed
362
+ 'Learn'
363
+ );
364
+ } catch (error) {
365
+ if (error.message.includes('timeout')) {
366
+ await finalizeOnTimeout(`Learn phase timeout: ${budget.learnMaxMs}ms`);
367
+ process.exit(0);
368
+ }
369
+ throw error;
370
+ }
371
+
259
372
  events.emit('phase:completed', {
260
373
  phase: 'Learn',
261
374
  message: 'Project analysis complete',
262
375
  });
263
376
 
264
- // Observe phase
377
+ // Observe phase with timeout
265
378
  events.emit('phase:started', {
266
379
  phase: 'Observe',
267
380
  message: 'Launching browser and observing expectations...',
268
381
  });
269
382
 
383
+ events.startHeartbeat('Observe', json);
384
+
270
385
  let observeData = null;
271
- if (expectations.length > 0) {
272
- try {
273
- observeData = await observeExpectations(
274
- expectations,
275
- resolvedUrl,
276
- paths.evidenceDir,
277
- (progress) => {
278
- events.emit(progress.event, progress);
279
- if (!json && progress.event === 'observe:result') {
280
- const status = progress.observed ? '✓' : '✗';
281
- console.log(` ${status} ${progress.index}/${expectations.length}`);
386
+ try {
387
+ if (expectations.length > 0) {
388
+ try {
389
+ observeData = await withTimeout(
390
+ budget.observeMaxMs,
391
+ observeExpectations(
392
+ expectations,
393
+ resolvedUrl,
394
+ paths.evidenceDir,
395
+ (progress) => {
396
+ events.emit(progress.event, progress);
397
+ if (!json && progress.event === 'observe:result') {
398
+ const status = progress.observed ? '✓' : '✗';
399
+ console.log(` ${status} ${progress.index}/${expectations.length}`);
400
+ }
401
+ }
402
+ ),
403
+ 'Observe'
404
+ );
405
+
406
+ if (!json) {
407
+ console.log(`Observed: ${observeData.stats.observed}/${expectations.length}`);
408
+ }
409
+ } catch (error) {
410
+ if (error.message.includes('timeout')) {
411
+ if (!json) {
412
+ console.error(`Observe error: timeout after ${budget.observeMaxMs}ms`);
413
+ }
414
+ events.emit('observe:error', {
415
+ message: `Observe phase timeout: ${budget.observeMaxMs}ms`,
416
+ });
417
+ observeData = {
418
+ observations: [],
419
+ stats: { attempted: 0, observed: 0, notObserved: 0 },
420
+ observedAt: new Date().toISOString(),
421
+ };
422
+ } else {
423
+ if (!json) {
424
+ console.error(`Observe error: ${error.message}`);
282
425
  }
426
+ events.emit('observe:error', {
427
+ message: error.message,
428
+ });
429
+ observeData = {
430
+ observations: [],
431
+ stats: { attempted: 0, observed: 0, notObserved: 0 },
432
+ observedAt: new Date().toISOString(),
433
+ };
283
434
  }
284
- );
285
-
286
- if (!json) {
287
- console.log(`Observed: ${observeData.stats.observed}/${expectations.length}`);
288
- }
289
- } catch (error) {
290
- if (!json) {
291
- console.error(`Observe error: ${error.message}`);
292
435
  }
293
- events.emit('observe:error', {
294
- message: error.message,
295
- });
436
+ } else {
296
437
  observeData = {
297
438
  observations: [],
298
439
  stats: { attempted: 0, observed: 0, notObserved: 0 },
299
440
  observedAt: new Date().toISOString(),
300
441
  };
301
442
  }
302
- } else {
303
- observeData = {
304
- observations: [],
305
- stats: { attempted: 0, observed: 0, notObserved: 0 },
306
- observedAt: new Date().toISOString(),
307
- };
443
+ } finally {
444
+ events.stopHeartbeat();
308
445
  }
309
446
 
310
447
  events.emit('phase:completed', {
@@ -312,47 +449,71 @@ export async function defaultCommand(options = {}) {
312
449
  message: 'Browser observation complete',
313
450
  });
314
451
 
315
- // Detect phase
452
+ // Detect phase with timeout
316
453
  events.emit('phase:started', {
317
454
  phase: 'Detect',
318
455
  message: 'Analyzing findings and detecting silent failures...',
319
456
  });
320
457
 
458
+ events.startHeartbeat('Detect', json);
459
+
321
460
  // Load learn and observe data for detection
322
461
  let learnData = { expectations: [] };
323
462
  let detectData = null;
324
463
 
325
464
  try {
326
- learnData = {
327
- expectations,
328
- skipped,
329
- };
330
-
331
- detectData = await detectFindings(learnData, observeData, projectRoot, (progress) => {
332
- events.emit(progress.event, progress);
333
- if (!json && progress.event === 'detect:classified') {
334
- const symbol = progress.classification === 'silent-failure' ? '✗' :
335
- progress.classification === 'observed' ? '✓' :
336
- progress.classification === 'coverage-gap' ? '⊘' : '⚠';
337
- console.log(` ${symbol} ${progress.index}/${learnData.expectations.length}`);
465
+ try {
466
+ learnData = {
467
+ expectations,
468
+ skipped,
469
+ };
470
+
471
+ detectData = await withTimeout(
472
+ budget.detectMaxMs,
473
+ detectFindings(learnData, observeData, projectRoot, (progress) => {
474
+ events.emit(progress.event, progress);
475
+ if (!json && progress.event === 'detect:classified') {
476
+ const symbol = progress.classification === 'silent-failure' ? '✗' :
477
+ progress.classification === 'observed' ? '✓' :
478
+ progress.classification === 'coverage-gap' ? '⊘' : '⚠';
479
+ console.log(` ${symbol} ${progress.index}/${learnData.expectations.length}`);
480
+ }
481
+ }),
482
+ 'Detect'
483
+ );
484
+
485
+ if (!json && detectData.stats.silentFailures > 0) {
486
+ console.log(`Silent failures detected: ${detectData.stats.silentFailures}`);
487
+ }
488
+ } catch (error) {
489
+ if (error.message.includes('timeout')) {
490
+ if (!json) {
491
+ console.error(`Detect error: timeout after ${budget.detectMaxMs}ms`);
492
+ }
493
+ events.emit('detect:error', {
494
+ message: `Detect phase timeout: ${budget.detectMaxMs}ms`,
495
+ });
496
+ detectData = {
497
+ findings: [],
498
+ stats: { total: 0, silentFailures: 0, observed: 0, coverageGaps: 0, unproven: 0, informational: 0 },
499
+ detectedAt: new Date().toISOString(),
500
+ };
501
+ } else {
502
+ if (!json) {
503
+ console.error(`Detect error: ${error.message}`);
504
+ }
505
+ events.emit('detect:error', {
506
+ message: error.message,
507
+ });
508
+ detectData = {
509
+ findings: [],
510
+ stats: { total: 0, silentFailures: 0, observed: 0, coverageGaps: 0, unproven: 0, informational: 0 },
511
+ detectedAt: new Date().toISOString(),
512
+ };
338
513
  }
339
- });
340
-
341
- if (!json && detectData.stats.silentFailures > 0) {
342
- console.log(`Silent failures detected: ${detectData.stats.silentFailures}`);
343
- }
344
- } catch (error) {
345
- if (!json) {
346
- console.error(`Detect error: ${error.message}`);
347
514
  }
348
- events.emit('detect:error', {
349
- message: error.message,
350
- });
351
- detectData = {
352
- findings: [],
353
- stats: { total: 0, silentFailures: 0, observed: 0, coverageGaps: 0, unproven: 0, informational: 0 },
354
- detectedAt: new Date().toISOString(),
355
- };
515
+ } finally {
516
+ events.stopHeartbeat();
356
517
  }
357
518
 
358
519
  events.emit('phase:completed', {
@@ -360,12 +521,20 @@ export async function defaultCommand(options = {}) {
360
521
  message: 'Silent failure detection complete',
361
522
  });
362
523
 
524
+ // Clear watchdog timer on successful completion
525
+ if (watchdogTimer) {
526
+ clearTimeout(watchdogTimer);
527
+ watchdogTimer = null;
528
+ }
529
+
363
530
  // Finalize Artifacts
364
531
  events.emit('phase:started', {
365
532
  phase: 'Finalize Artifacts',
366
533
  message: 'Writing run results...',
367
534
  });
368
535
 
536
+ events.stopHeartbeat();
537
+
369
538
  const completedAt = new Date().toISOString();
370
539
 
371
540
  atomicWriteJson(paths.runStatusJson, {
@@ -411,30 +580,11 @@ export async function defaultCommand(options = {}) {
411
580
  // Write detect results (or empty if detection failed)
412
581
  writeFindingsJson(paths.baseDir, detectData);
413
582
 
414
- const traces = [
415
- {
416
- type: 'phase:started',
417
- timestamp: startedAt,
418
- phase: 'Detect Project',
419
- },
420
- {
421
- type: 'phase:completed',
422
- timestamp: new Date().toISOString(),
423
- phase: 'Detect Project',
424
- },
425
- {
426
- type: 'phase:started',
427
- timestamp: new Date().toISOString(),
428
- phase: 'Learn',
429
- },
430
- {
431
- type: 'phase:completed',
432
- timestamp: completedAt,
433
- phase: 'Learn',
434
- },
435
- ];
436
-
437
- const tracesContent = traces.map(t => JSON.stringify(t)).join('\n') + '\n';
583
+ // Write traces (include all events including heartbeats)
584
+ const allEvents = events.getEvents();
585
+ const tracesContent = allEvents
586
+ .map(e => JSON.stringify(e))
587
+ .join('\n') + '\n';
438
588
  atomicWriteText(paths.tracesJsonl, tracesContent);
439
589
 
440
590
  // Write project profile
@@ -460,8 +610,16 @@ export async function defaultCommand(options = {}) {
460
610
 
461
611
  return { runId, paths, url: resolvedUrl, success: true };
462
612
  } catch (error) {
613
+ // Clear watchdog timer on error
614
+ if (watchdogTimer) {
615
+ clearTimeout(watchdogTimer);
616
+ watchdogTimer = null;
617
+ }
618
+
619
+ events.stopHeartbeat();
620
+
463
621
  // Mark run as FAILED if we have paths
464
- if (paths && runId && startedAt) {
622
+ if (paths && runId && startedAt && typeof paths === 'object') {
465
623
  try {
466
624
  const failedAt = new Date().toISOString();
467
625
  atomicWriteJson(paths.runStatusJson, {
@@ -92,11 +92,26 @@ export async function doctorCommand(options = {}) {
92
92
 
93
93
  // 4) Headless launch smoke test
94
94
  if (playwright && playwright.chromium && chromiumPath && existsSync(chromiumPath)) {
95
+ /** @type {any} */
96
+ let browser = null;
95
97
  try {
96
- const browser = await playwright.chromium.launch({ headless: true });
97
- const page = await browser.newPage();
98
- await page.goto('about:blank');
99
- await browser.close();
98
+ // Configurable timeout via env (default 5000ms, CI can override to 3000ms)
99
+ const smokeTimeoutMs = parseInt(process.env.VERAX_DOCTOR_SMOKE_TIMEOUT_MS || '5000', 10);
100
+
101
+ // Wrap entire smoke test in hard timeout
102
+ const smokeTestPromise = (async () => {
103
+ browser = await playwright.chromium.launch({ headless: true, timeout: smokeTimeoutMs });
104
+ const page = await browser.newPage();
105
+ await page.goto('about:blank', { timeout: smokeTimeoutMs });
106
+ return true;
107
+ })();
108
+
109
+ // Race against timeout
110
+ const timeoutPromise = new Promise((_, reject) => {
111
+ setTimeout(() => reject(new Error('Smoke test timed out')), smokeTimeoutMs);
112
+ });
113
+
114
+ await Promise.race([smokeTestPromise, timeoutPromise]);
100
115
  addCheck('Headless smoke test', 'pass', 'Chromium launched headless and closed successfully');
101
116
  } catch (error) {
102
117
  addCheck(
@@ -107,6 +122,23 @@ export async function doctorCommand(options = {}) {
107
122
  ? 'Try: npx playwright install --with-deps chromium && launch with --no-sandbox in constrained environments'
108
123
  : 'Reinstall playwright: npx playwright install --with-deps chromium'
109
124
  );
125
+ } finally {
126
+ // CRITICAL: Always close browser to prevent hanging processes
127
+ // Bound close operation too (max 2s)
128
+ if (browser) {
129
+ try {
130
+ const closePromise = browser.close();
131
+ const closeTimeout = new Promise((resolve) => setTimeout(resolve, 2000));
132
+ await Promise.race([closePromise, closeTimeout]);
133
+ } catch (closeError) {
134
+ // Ignore close errors - force kill if needed
135
+ try {
136
+ await browser.close();
137
+ } catch {
138
+ // Final attempt failed, process will clean up
139
+ }
140
+ }
141
+ }
110
142
  }
111
143
  } else {
112
144
  addCheck(