browsecraft-runner 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -183,6 +183,807 @@ var TestRunner = class {
183
183
  }
184
184
  };
185
185
 
186
+ // src/event-bus.ts
187
+ var EventBus = class {
188
+ listeners = /* @__PURE__ */ new Map();
189
+ history = [];
190
+ _recordHistory = false;
191
+ // -----------------------------------------------------------------------
192
+ // Subscription
193
+ // -----------------------------------------------------------------------
194
+ /**
195
+ * Register a listener for an event.
196
+ * Returns an unsubscribe function for easy cleanup.
197
+ */
198
+ on(event, listener) {
199
+ let set = this.listeners.get(event);
200
+ if (!set) {
201
+ set = /* @__PURE__ */ new Set();
202
+ this.listeners.set(event, set);
203
+ }
204
+ set.add(listener);
205
+ return () => {
206
+ set.delete(listener);
207
+ if (set.size === 0) this.listeners.delete(event);
208
+ };
209
+ }
210
+ /**
211
+ * Register a one-time listener. Automatically removed after first call.
212
+ */
213
+ once(event, listener) {
214
+ const unsubscribe = this.on(event, ((payload) => {
215
+ unsubscribe();
216
+ listener(payload);
217
+ }));
218
+ return unsubscribe;
219
+ }
220
+ /**
221
+ * Remove all listeners for a specific event, or all events.
222
+ */
223
+ off(event) {
224
+ if (event) {
225
+ this.listeners.delete(event);
226
+ } else {
227
+ this.listeners.clear();
228
+ }
229
+ }
230
+ // -----------------------------------------------------------------------
231
+ // Emission
232
+ // -----------------------------------------------------------------------
233
+ /**
234
+ * Emit an event synchronously to all registered listeners.
235
+ */
236
+ emit(event, payload) {
237
+ if (this._recordHistory) {
238
+ this.history.push({ event, payload, timestamp: Date.now() });
239
+ }
240
+ const set = this.listeners.get(event);
241
+ if (!set) return;
242
+ for (const listener of set) {
243
+ try {
244
+ listener(payload);
245
+ } catch {
246
+ }
247
+ }
248
+ }
249
+ // -----------------------------------------------------------------------
250
+ // Introspection
251
+ // -----------------------------------------------------------------------
252
+ /**
253
+ * Get the number of listeners for a specific event, or all events.
254
+ */
255
+ listenerCount(event) {
256
+ if (event) {
257
+ return this.listeners.get(event)?.size ?? 0;
258
+ }
259
+ let total = 0;
260
+ for (const set of this.listeners.values()) {
261
+ total += set.size;
262
+ }
263
+ return total;
264
+ }
265
+ /**
266
+ * Get the names of all events that have listeners.
267
+ */
268
+ eventNames() {
269
+ return Array.from(this.listeners.keys());
270
+ }
271
+ // -----------------------------------------------------------------------
272
+ // History (for debugging / test assertions)
273
+ // -----------------------------------------------------------------------
274
+ /**
275
+ * Enable event history recording. Useful for tests and debugging.
276
+ */
277
+ enableHistory() {
278
+ this._recordHistory = true;
279
+ }
280
+ /**
281
+ * Disable event history recording and clear existing history.
282
+ */
283
+ disableHistory() {
284
+ this._recordHistory = false;
285
+ this.history = [];
286
+ }
287
+ /**
288
+ * Get recorded events. Only available when history is enabled.
289
+ */
290
+ getHistory() {
291
+ return this.history;
292
+ }
293
+ /**
294
+ * Get events of a specific type from history.
295
+ */
296
+ getEventsOfType(event) {
297
+ return this.history.filter((h) => h.event === event).map((h) => ({ payload: h.payload, timestamp: h.timestamp }));
298
+ }
299
+ /**
300
+ * Clear all history without disabling recording.
301
+ */
302
+ clearHistory() {
303
+ this.history = [];
304
+ }
305
+ };
306
+
307
+ // src/worker-pool.ts
308
+ var DEFAULT_POOL_CONFIG = {
309
+ browsers: { chrome: 1 },
310
+ maxRetries: 0,
311
+ bail: false,
312
+ spawnTimeout: 3e4
313
+ };
314
+ var WorkerPool = class {
315
+ bus;
316
+ config;
317
+ workers = [];
318
+ bailed = false;
319
+ constructor(bus, config) {
320
+ this.bus = bus;
321
+ this.config = { ...DEFAULT_POOL_CONFIG, ...config };
322
+ }
323
+ // -----------------------------------------------------------------------
324
+ // Pool info
325
+ // -----------------------------------------------------------------------
326
+ /** Get a snapshot of all workers */
327
+ getWorkers() {
328
+ return this.workers;
329
+ }
330
+ /** Get workers for a specific browser */
331
+ getWorkersForBrowser(browser) {
332
+ return this.workers.filter((w) => w.info.browser === browser);
333
+ }
334
+ /** Get idle workers */
335
+ getIdleWorkers() {
336
+ return this.workers.filter((w) => w.state === "idle");
337
+ }
338
+ /** Total number of workers across all browsers */
339
+ get size() {
340
+ return this.workers.length;
341
+ }
342
+ /** Number of distinct browser types */
343
+ get browserCount() {
344
+ return new Set(this.workers.map((w) => w.info.browser)).size;
345
+ }
346
+ /** Get browser names in the pool */
347
+ get browserNames() {
348
+ return [...new Set(this.workers.map((w) => w.info.browser))];
349
+ }
350
+ // -----------------------------------------------------------------------
351
+ // Spawn
352
+ // -----------------------------------------------------------------------
353
+ /**
354
+ * Create all workers and spawn their browser instances.
355
+ * Workers are created based on the browsers config.
356
+ */
357
+ async spawn(spawner) {
358
+ const entries = Object.entries(this.config.browsers);
359
+ for (const [browser, count] of entries) {
360
+ for (let i = 0; i < count; i++) {
361
+ const info = {
362
+ id: `${browser}-${i}`,
363
+ browser,
364
+ index: i
365
+ };
366
+ this.workers.push({
367
+ info,
368
+ state: "starting",
369
+ currentItem: null,
370
+ completedCount: 0,
371
+ spawnedAt: Date.now(),
372
+ cleanup: null
373
+ });
374
+ this.bus.emit("worker:spawn", info);
375
+ }
376
+ }
377
+ const spawnPromises = this.workers.map(async (worker) => {
378
+ try {
379
+ const result = await this.withTimeout(
380
+ spawner(worker.info),
381
+ this.config.spawnTimeout,
382
+ `Worker ${worker.info.id} spawn timed out after ${this.config.spawnTimeout}ms`
383
+ );
384
+ worker.cleanup = result.close;
385
+ worker.state = "idle";
386
+ this.bus.emit("worker:ready", worker.info);
387
+ } catch (err) {
388
+ worker.state = "error";
389
+ const error = err instanceof Error ? err : new Error(String(err));
390
+ this.bus.emit("worker:error", { worker: worker.info, error });
391
+ throw error;
392
+ }
393
+ });
394
+ await Promise.all(spawnPromises);
395
+ }
396
+ // -----------------------------------------------------------------------
397
+ // Execute
398
+ // -----------------------------------------------------------------------
399
+ /**
400
+ * Execute a list of work items across all available workers.
401
+ * Items are distributed using shortest-queue / round-robin.
402
+ * Returns all results.
403
+ */
404
+ async execute(items, executor) {
405
+ if (items.length === 0) return [];
406
+ const results = [];
407
+ const queue = [...items];
408
+ const activeWorkers = this.workers.filter((w) => w.state === "idle");
409
+ if (activeWorkers.length === 0) {
410
+ throw new Error("No active workers available. Did you call spawn() first?");
411
+ }
412
+ for (const item of items) {
413
+ this.bus.emit("item:enqueue", item);
414
+ }
415
+ const workerPromises = activeWorkers.map(
416
+ (worker) => this.workerLoop(worker, queue, results, executor)
417
+ );
418
+ await Promise.all(workerPromises);
419
+ return results;
420
+ }
421
+ /**
422
+ * Execute items only on workers for a specific browser.
423
+ * Used for sequential browser-by-browser execution.
424
+ */
425
+ async executeOnBrowser(browser, items, executor) {
426
+ if (items.length === 0) return [];
427
+ const results = [];
428
+ const queue = [...items];
429
+ const browserWorkers = this.workers.filter(
430
+ (w) => w.info.browser === browser && w.state === "idle"
431
+ );
432
+ if (browserWorkers.length === 0) {
433
+ throw new Error(`No active workers for browser: ${browser}`);
434
+ }
435
+ for (const item of items) {
436
+ this.bus.emit("item:enqueue", item);
437
+ }
438
+ const workerPromises = browserWorkers.map(
439
+ (worker) => this.workerLoop(worker, queue, results, executor)
440
+ );
441
+ await Promise.all(workerPromises);
442
+ return results;
443
+ }
444
+ // -----------------------------------------------------------------------
445
+ // Worker loop
446
+ // -----------------------------------------------------------------------
447
+ /**
448
+ * Each worker runs this loop: pull from queue → execute → repeat.
449
+ * This is the work-stealing pattern — workers pull items themselves.
450
+ */
451
+ async workerLoop(worker, queue, results, executor) {
452
+ while (queue.length > 0 && !this.bailed) {
453
+ const item = queue.shift();
454
+ if (!item) break;
455
+ worker.state = "busy";
456
+ worker.currentItem = item;
457
+ this.bus.emit("worker:busy", { worker: worker.info, item });
458
+ this.bus.emit("item:start", { item, worker: worker.info });
459
+ let finalResult;
460
+ try {
461
+ const execResult = await executor(item, worker.info);
462
+ finalResult = {
463
+ item,
464
+ worker: worker.info,
465
+ status: execResult.status,
466
+ duration: execResult.duration,
467
+ error: execResult.error
468
+ };
469
+ if (execResult.status === "failed" && this.config.maxRetries > 0) {
470
+ let attempt = 1;
471
+ while (attempt <= this.config.maxRetries && finalResult.status === "failed") {
472
+ this.bus.emit("item:retry", {
473
+ item,
474
+ worker: worker.info,
475
+ attempt,
476
+ maxRetries: this.config.maxRetries
477
+ });
478
+ const retryResult = await executor(item, worker.info);
479
+ finalResult = {
480
+ item,
481
+ worker: worker.info,
482
+ status: retryResult.status,
483
+ duration: retryResult.duration,
484
+ error: retryResult.error,
485
+ retries: attempt
486
+ };
487
+ attempt++;
488
+ }
489
+ }
490
+ } catch (err) {
491
+ const error = err instanceof Error ? err : new Error(String(err));
492
+ finalResult = {
493
+ item,
494
+ worker: worker.info,
495
+ status: "failed",
496
+ duration: 0,
497
+ error
498
+ };
499
+ }
500
+ results.push(finalResult);
501
+ worker.completedCount++;
502
+ worker.currentItem = null;
503
+ worker.state = "idle";
504
+ switch (finalResult.status) {
505
+ case "passed":
506
+ this.bus.emit("item:pass", finalResult);
507
+ break;
508
+ case "failed":
509
+ this.bus.emit("item:fail", finalResult);
510
+ break;
511
+ case "skipped":
512
+ this.bus.emit("item:skip", finalResult);
513
+ break;
514
+ }
515
+ this.bus.emit("item:end", finalResult);
516
+ this.bus.emit("worker:idle", worker.info);
517
+ if (finalResult.status === "failed" && this.config.bail) {
518
+ this.bailed = true;
519
+ break;
520
+ }
521
+ }
522
+ }
523
+ // -----------------------------------------------------------------------
524
+ // Terminate
525
+ // -----------------------------------------------------------------------
526
+ /**
527
+ * Gracefully terminate all workers and close their browsers.
528
+ */
529
+ async terminate() {
530
+ const closePromises = this.workers.map(async (worker) => {
531
+ if (worker.cleanup) {
532
+ try {
533
+ await worker.cleanup();
534
+ } catch {
535
+ }
536
+ }
537
+ worker.state = "terminated";
538
+ worker.cleanup = null;
539
+ this.bus.emit("worker:terminate", worker.info);
540
+ });
541
+ await Promise.all(closePromises);
542
+ }
543
+ /**
544
+ * Reset the pool — clear all workers and state. Used between runs.
545
+ */
546
+ reset() {
547
+ this.workers.length = 0;
548
+ this.bailed = false;
549
+ }
550
+ // -----------------------------------------------------------------------
551
+ // Utilities
552
+ // -----------------------------------------------------------------------
553
+ withTimeout(promise, ms, message) {
554
+ return new Promise((resolve2, reject) => {
555
+ const timer = setTimeout(() => reject(new Error(message)), ms);
556
+ promise.then((value) => {
557
+ clearTimeout(timer);
558
+ resolve2(value);
559
+ }).catch((err) => {
560
+ clearTimeout(timer);
561
+ reject(err);
562
+ });
563
+ });
564
+ }
565
+ };
566
+
567
+ // src/scheduler.ts
568
+ var DEFAULT_SCHEDULER_CONFIG = {
569
+ strategy: "matrix"
570
+ };
571
+ var Scheduler = class {
572
+ bus;
573
+ pool;
574
+ config;
575
+ constructor(bus, pool, config) {
576
+ this.bus = bus;
577
+ this.pool = pool;
578
+ this.config = { ...DEFAULT_SCHEDULER_CONFIG, ...config };
579
+ }
580
+ // -----------------------------------------------------------------------
581
+ // Run
582
+ // -----------------------------------------------------------------------
583
+ /**
584
+ * Execute work items according to the configured strategy.
585
+ */
586
+ async run(items, executor) {
587
+ const startTime = Date.now();
588
+ const browsers = this.pool.browserNames;
589
+ const filteredItems = this.applyFilters(items);
590
+ this.bus.emit("run:start", {
591
+ browsers,
592
+ totalItems: filteredItems.length,
593
+ workers: this.pool.size
594
+ });
595
+ let allResults;
596
+ switch (this.config.strategy) {
597
+ case "parallel":
598
+ allResults = await this.runParallel(filteredItems, executor, browsers);
599
+ break;
600
+ case "sequential":
601
+ allResults = await this.runSequential(filteredItems, executor, browsers);
602
+ break;
603
+ case "matrix":
604
+ allResults = await this.runMatrix(filteredItems, executor, browsers);
605
+ break;
606
+ default:
607
+ throw new Error(`Unknown execution strategy: ${this.config.strategy}`);
608
+ }
609
+ const totalDuration = Date.now() - startTime;
610
+ const schedulerResult = this.buildResult(allResults, browsers, totalDuration);
611
+ this.bus.emit("run:end", {
612
+ duration: totalDuration,
613
+ results: allResults
614
+ });
615
+ return schedulerResult;
616
+ }
617
+ // -----------------------------------------------------------------------
618
+ // Strategies
619
+ // -----------------------------------------------------------------------
620
+ /**
621
+ * PARALLEL: All workers across all browsers pull from a single shared queue.
622
+ * Scenarios are distributed across all available workers.
623
+ * A scenario may run on any browser — depends on which worker picks it up.
624
+ */
625
+ async runParallel(items, executor, browsers) {
626
+ for (const browser of browsers) {
627
+ const workerCount = this.pool.getWorkersForBrowser(browser).length;
628
+ this.bus.emit("browser:start", {
629
+ browser,
630
+ workerCount,
631
+ itemCount: items.length
632
+ });
633
+ }
634
+ const results = await this.pool.execute(items, executor);
635
+ for (const browser of browsers) {
636
+ const browserResults = results.filter((r) => r.worker.browser === browser);
637
+ this.bus.emit("browser:end", {
638
+ browser,
639
+ passed: browserResults.filter((r) => r.status === "passed").length,
640
+ failed: browserResults.filter((r) => r.status === "failed").length,
641
+ skipped: browserResults.filter((r) => r.status === "skipped").length,
642
+ duration: browserResults.reduce((sum, r) => sum + r.duration, 0)
643
+ });
644
+ }
645
+ return results;
646
+ }
647
+ /**
648
+ * SEQUENTIAL: One browser at a time. Each browser gets the full scenario set.
649
+ * Useful for isolated browser runs or resource-constrained environments.
650
+ */
651
+ async runSequential(items, executor, browsers) {
652
+ const allResults = [];
653
+ for (const browser of browsers) {
654
+ const workerCount = this.pool.getWorkersForBrowser(browser).length;
655
+ this.bus.emit("browser:start", {
656
+ browser,
657
+ workerCount,
658
+ itemCount: items.length
659
+ });
660
+ const browserStart = Date.now();
661
+ const results = await this.pool.executeOnBrowser(browser, items, executor);
662
+ allResults.push(...results);
663
+ this.bus.emit("browser:end", {
664
+ browser,
665
+ passed: results.filter((r) => r.status === "passed").length,
666
+ failed: results.filter((r) => r.status === "failed").length,
667
+ skipped: results.filter((r) => r.status === "skipped").length,
668
+ duration: Date.now() - browserStart
669
+ });
670
+ }
671
+ return allResults;
672
+ }
673
+ /**
674
+ * MATRIX: Every scenario runs on every browser.
675
+ * Total executions = scenarios × browsers.
676
+ * Within each browser, scenarios run in parallel across that browser's workers.
677
+ *
678
+ * This is the gold standard for cross-browser testing — guarantees
679
+ * every scenario is validated on every browser.
680
+ */
681
+ async runMatrix(items, executor, browsers) {
682
+ if (browsers.length <= 1) {
683
+ return this.runParallel(items, executor, browsers);
684
+ }
685
+ const browserPromises = browsers.map(async (browser) => {
686
+ const workerCount = this.pool.getWorkersForBrowser(browser).length;
687
+ this.bus.emit("browser:start", {
688
+ browser,
689
+ workerCount,
690
+ itemCount: items.length
691
+ });
692
+ const browserItems = items.map((item) => ({ ...item }));
693
+ const browserStart = Date.now();
694
+ const results = await this.pool.executeOnBrowser(browser, browserItems, executor);
695
+ this.bus.emit("browser:end", {
696
+ browser,
697
+ passed: results.filter((r) => r.status === "passed").length,
698
+ failed: results.filter((r) => r.status === "failed").length,
699
+ skipped: results.filter((r) => r.status === "skipped").length,
700
+ duration: Date.now() - browserStart
701
+ });
702
+ return results;
703
+ });
704
+ const browserResultSets = await Promise.all(browserPromises);
705
+ return browserResultSets.flat();
706
+ }
707
+ // -----------------------------------------------------------------------
708
+ // Filtering
709
+ // -----------------------------------------------------------------------
710
+ /**
711
+ * Apply grep and tag filters to work items.
712
+ */
713
+ applyFilters(items) {
714
+ let filtered = items;
715
+ if (this.config.grep) {
716
+ const pattern = this.config.grep;
717
+ filtered = filtered.filter((item) => item.title.includes(pattern));
718
+ }
719
+ if (this.config.tagFilter) {
720
+ const tagExpr = this.config.tagFilter.toLowerCase();
721
+ filtered = filtered.filter((item) => {
722
+ if (!item.tags || item.tags.length === 0) return false;
723
+ const itemTags = item.tags.map((t) => t.toLowerCase());
724
+ return this.matchTagExpression(itemTags, tagExpr);
725
+ });
726
+ }
727
+ return filtered;
728
+ }
729
+ /**
730
+ * Simple tag expression matcher.
731
+ * Supports: @tag, @tag1 and @tag2, @tag1 or @tag2, not @tag
732
+ */
733
+ matchTagExpression(tags, expression) {
734
+ if (expression.includes(" and ")) {
735
+ return expression.split(" and ").every((part) => this.matchTagExpression(tags, part.trim()));
736
+ }
737
+ if (expression.includes(" or ")) {
738
+ return expression.split(" or ").some((part) => this.matchTagExpression(tags, part.trim()));
739
+ }
740
+ if (expression.startsWith("not ")) {
741
+ return !this.matchTagExpression(tags, expression.slice(4).trim());
742
+ }
743
+ const tag = expression.startsWith("@") ? expression : `@${expression}`;
744
+ return tags.includes(tag);
745
+ }
746
+ // -----------------------------------------------------------------------
747
+ // Result building
748
+ // -----------------------------------------------------------------------
749
+ /**
750
+ * Build the final SchedulerResult from collected results.
751
+ */
752
+ buildResult(allResults, browsers, totalDuration) {
753
+ const browserResults = browsers.map((browser) => {
754
+ const results = allResults.filter((r) => r.worker.browser === browser);
755
+ return {
756
+ browser,
757
+ results,
758
+ passed: results.filter((r) => r.status === "passed").length,
759
+ failed: results.filter((r) => r.status === "failed").length,
760
+ skipped: results.filter((r) => r.status === "skipped").length,
761
+ duration: results.reduce((sum, r) => sum + r.duration, 0)
762
+ };
763
+ });
764
+ return {
765
+ browsers: browserResults,
766
+ allResults,
767
+ totalPassed: allResults.filter((r) => r.status === "passed").length,
768
+ totalFailed: allResults.filter((r) => r.status === "failed").length,
769
+ totalSkipped: allResults.filter((r) => r.status === "skipped").length,
770
+ totalDuration,
771
+ strategy: this.config.strategy
772
+ };
773
+ }
774
+ };
775
+
776
+ // src/result-aggregator.ts
777
+ var ResultAggregator = class {
778
+ /**
779
+ * Aggregate a SchedulerResult into a complete summary.
780
+ */
781
+ aggregate(result) {
782
+ const { allResults, browsers: browserResults, strategy, totalDuration } = result;
783
+ const matrix = this.buildMatrix(
784
+ allResults,
785
+ browserResults.map((b) => b.browser)
786
+ );
787
+ const browserSummaries = browserResults.map((b) => ({
788
+ browser: b.browser,
789
+ passed: b.passed,
790
+ failed: b.failed,
791
+ skipped: b.skipped,
792
+ duration: b.duration
793
+ }));
794
+ const uniqueScenarios = new Set(allResults.map((r) => r.item.id));
795
+ const flaky = matrix.filter((r) => r.flaky);
796
+ const inconsistent = matrix.filter((r) => r.crossBrowserInconsistent);
797
+ const totals = {
798
+ scenarios: uniqueScenarios.size,
799
+ passed: result.totalPassed,
800
+ failed: result.totalFailed,
801
+ skipped: result.totalSkipped,
802
+ flaky: flaky.length,
803
+ crossBrowserInconsistent: inconsistent.length
804
+ };
805
+ const allDurations = allResults.filter((r) => r.status !== "skipped").map((r) => r.duration);
806
+ const timing = this.computeTimingStats(allDurations);
807
+ const flakyTests = flaky.map((r) => r.title);
808
+ const inconsistentTests = inconsistent.map((r) => r.title);
809
+ const slowestTests = [...allResults].filter((r) => r.status !== "skipped").sort((a, b) => b.duration - a.duration).slice(0, 5).map((r) => ({
810
+ title: r.item.title,
811
+ duration: r.duration,
812
+ browser: r.worker.browser
813
+ }));
814
+ const failedTests = allResults.filter((r) => r.status === "failed").map((r) => ({
815
+ title: r.item.title,
816
+ error: r.error?.message,
817
+ browser: r.worker.browser
818
+ }));
819
+ return {
820
+ matrix,
821
+ browserSummaries,
822
+ totals,
823
+ timing,
824
+ strategy,
825
+ totalDuration,
826
+ browsers: browserResults.map((b) => b.browser),
827
+ flakyTests,
828
+ inconsistentTests,
829
+ slowestTests,
830
+ failedTests
831
+ };
832
+ }
833
+ // -----------------------------------------------------------------------
834
+ // Matrix building
835
+ // -----------------------------------------------------------------------
836
+ /**
837
+ * Build the scenario × browser matrix.
838
+ */
839
+ buildMatrix(results, browsers) {
840
+ const byId = /* @__PURE__ */ new Map();
841
+ for (const result of results) {
842
+ const existing = byId.get(result.item.id) ?? [];
843
+ existing.push(result);
844
+ byId.set(result.item.id, existing);
845
+ }
846
+ const rows = [];
847
+ for (const [id, itemResults] of byId) {
848
+ const firstResult = itemResults[0];
849
+ const browserMap = /* @__PURE__ */ new Map();
850
+ for (const browser of browsers) {
851
+ const browserResult = itemResults.find((r) => r.worker.browser === browser);
852
+ if (browserResult) {
853
+ browserMap.set(browser, {
854
+ status: browserResult.status,
855
+ duration: browserResult.duration,
856
+ error: browserResult.error,
857
+ retries: browserResult.retries
858
+ });
859
+ } else {
860
+ browserMap.set(browser, {
861
+ status: "not-run",
862
+ duration: 0
863
+ });
864
+ }
865
+ }
866
+ const statuses = new Set(
867
+ [...browserMap.values()].filter((c) => c.status !== "not-run").map((c) => c.status)
868
+ );
869
+ const crossBrowserInconsistent = statuses.size > 1;
870
+ const flaky = itemResults.some(
871
+ (r) => r.retries !== void 0 && r.retries > 0 && r.status === "passed"
872
+ );
873
+ rows.push({
874
+ id,
875
+ title: firstResult.item.title,
876
+ file: firstResult.item.file,
877
+ suitePath: firstResult.item.suitePath,
878
+ browsers: browserMap,
879
+ crossBrowserInconsistent,
880
+ flaky
881
+ });
882
+ }
883
+ return rows;
884
+ }
885
+ // -----------------------------------------------------------------------
886
+ // Timing statistics
887
+ // -----------------------------------------------------------------------
888
+ /**
889
+ * Compute timing statistics from an array of durations.
890
+ */
891
+ computeTimingStats(durations) {
892
+ if (durations.length === 0) {
893
+ return { min: 0, max: 0, avg: 0, median: 0, p95: 0, total: 0 };
894
+ }
895
+ const sorted = [...durations].sort((a, b) => a - b);
896
+ const total = sorted.reduce((sum, d) => sum + d, 0);
897
+ return {
898
+ min: sorted[0],
899
+ max: sorted[sorted.length - 1],
900
+ avg: Math.round(total / sorted.length),
901
+ median: sorted[Math.floor(sorted.length / 2)],
902
+ p95: sorted[Math.floor(sorted.length * 0.95)],
903
+ total
904
+ };
905
+ }
906
+ // -----------------------------------------------------------------------
907
+ // Formatting helpers (for CLI console output)
908
+ // -----------------------------------------------------------------------
909
+ /**
910
+ * Format the matrix as a console-friendly table string.
911
+ */
912
+ formatMatrix(summary) {
913
+ const lines = [];
914
+ const { browsers, matrix } = summary;
915
+ const browserHeaders = browsers.map((b) => b.padEnd(10)).join(" ");
916
+ lines.push(`
917
+ ${"Scenario".padEnd(40)} ${browserHeaders}`);
918
+ lines.push(` ${"\u2500".repeat(40)} ${"\u2500".repeat(browsers.length * 11)}`);
919
+ for (const row of matrix) {
920
+ const title = row.title.length > 38 ? `${row.title.slice(0, 35)}...` : row.title;
921
+ const cells = browsers.map((browser) => {
922
+ const cell = row.browsers.get(browser);
923
+ if (!cell || cell.status === "not-run") return " - ".padEnd(10);
924
+ const icon = cell.status === "passed" ? "\x1B[32m\u2713\x1B[0m" : cell.status === "failed" ? "\x1B[31m\u2717\x1B[0m" : "\x1B[33m-\x1B[0m";
925
+ const time = cell.duration < 1e3 ? `${cell.duration}ms` : `${(cell.duration / 1e3).toFixed(1)}s`;
926
+ return `${icon} ${time}`.padEnd(10);
927
+ });
928
+ const flag = row.flaky ? " \u{1F504}" : row.crossBrowserInconsistent ? " \u26A0\uFE0F" : "";
929
+ lines.push(` ${title.padEnd(40)} ${cells.join(" ")}${flag}`);
930
+ }
931
+ lines.push("");
932
+ lines.push(" Legend: \u2713 passed \u2717 failed - skipped \u{1F504} flaky \u26A0\uFE0F inconsistent");
933
+ lines.push("");
934
+ return lines.join("\n");
935
+ }
936
+ /**
937
+ * Format a concise summary string for console output.
938
+ */
939
+ formatSummary(summary) {
940
+ const lines = [];
941
+ const { totals, timing, browsers, totalDuration, strategy } = summary;
942
+ lines.push(" \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550");
943
+ lines.push(` Strategy: ${strategy} | Browsers: ${browsers.join(", ")}`);
944
+ lines.push(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
945
+ for (const bs of summary.browserSummaries) {
946
+ const parts = [];
947
+ if (bs.passed > 0) parts.push(`\x1B[32m${bs.passed} \u2713\x1B[0m`);
948
+ if (bs.failed > 0) parts.push(`\x1B[31m${bs.failed} \u2717\x1B[0m`);
949
+ if (bs.skipped > 0) parts.push(`\x1B[33m${bs.skipped} -\x1B[0m`);
950
+ lines.push(
951
+ ` ${bs.browser.padEnd(10)} ${parts.join(" ")} (${this.formatDuration(bs.duration)})`
952
+ );
953
+ }
954
+ lines.push(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
955
+ const totalParts = [];
956
+ if (totals.passed > 0) totalParts.push(`\x1B[32m${totals.passed} passed\x1B[0m`);
957
+ if (totals.failed > 0) totalParts.push(`\x1B[31m${totals.failed} failed\x1B[0m`);
958
+ if (totals.skipped > 0) totalParts.push(`\x1B[33m${totals.skipped} skipped\x1B[0m`);
959
+ lines.push(` Total: ${totalParts.join(", ")} (${totals.scenarios} scenarios)`);
960
+ if (totals.flaky > 0) {
961
+ lines.push(` Flaky: \x1B[33m${totals.flaky} tests\x1B[0m`);
962
+ }
963
+ if (totals.crossBrowserInconsistent > 0) {
964
+ lines.push(` \u26A0\uFE0F Cross-browser inconsistencies: ${totals.crossBrowserInconsistent}`);
965
+ }
966
+ lines.push(
967
+ ` Timing: avg ${this.formatDuration(timing.avg)} \xB7 p95 ${this.formatDuration(timing.p95)} \xB7 max ${this.formatDuration(timing.max)}`
968
+ );
969
+ lines.push(` Duration: ${this.formatDuration(totalDuration)}`);
970
+ lines.push(" \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550");
971
+ lines.push("");
972
+ return lines.join("\n");
973
+ }
974
+ formatDuration(ms) {
975
+ if (ms < 1e3) return `${ms}ms`;
976
+ if (ms < 6e4) return `${(ms / 1e3).toFixed(1)}s`;
977
+ const minutes = Math.floor(ms / 6e4);
978
+ const seconds = (ms % 6e4 / 1e3).toFixed(1);
979
+ return `${minutes}m ${seconds}s`;
980
+ }
981
+ };
982
+
983
+ exports.EventBus = EventBus;
984
+ exports.ResultAggregator = ResultAggregator;
985
+ exports.Scheduler = Scheduler;
186
986
  exports.TestRunner = TestRunner;
987
+ exports.WorkerPool = WorkerPool;
187
988
  //# sourceMappingURL=index.cjs.map
188
989
  //# sourceMappingURL=index.cjs.map