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 +801 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +504 -1
- package/dist/index.d.ts +504 -1
- package/dist/index.js +798 -1
- package/dist/index.js.map +1 -1
- package/package.json +2 -4
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
|