reporting-labs 0.5.0 → 0.6.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.
package/README.md CHANGED
@@ -8,6 +8,10 @@ reportingLabs turns a test run into a single HTML file. No server. No upload. No
8
8
 
9
9
  Today it ships with a **Playwright** reporter. WebdriverIO, Cypress and Jest/Vitest are on the roadmap.
10
10
 
11
+ <p align="center"><img src="https://raw.githubusercontent.com/naveenautomationlabs/reporting-labs/main/docs/quickstart.gif" alt="reportingLabs in three steps: install, tag your tests, open one HTML file" width="900"></p>
12
+
13
+ <p align="center"><em>Install, tag your tests, open one HTML file.</em></p>
14
+
11
15
  <p align="center"><img src="https://raw.githubusercontent.com/naveenautomationlabs/reporting-labs/main/docs/overview.png" alt="Overview page of a reportingLabs report" width="900"></p>
12
16
 
13
17
  ## Quick start (2 minutes)
@@ -263,7 +267,7 @@ Legend: ✅ built in · 🟡 possible with manual setup or extra config · ❌ n
263
267
  | Secrets masked | ❌ | 🟡 parameter masking | ✅ automatic |
264
268
  | CSV / JSON export of failures, Slack summary | ❌ | ❌ | ✅ |
265
269
  | Timeline by worker | ❌ | ✅ | ✅ |
266
- | Combine shards / several runs | ✅ blob + `merge-reports` | ✅ Launches | ❌ on the roadmap |
270
+ | Combine shards / several runs | ✅ blob + `merge-reports` | ✅ Launches | ✅ `reporting-labs merge` |
267
271
  | Frameworks beyond Playwright | ❌ | ✅ many languages | ❌ on the roadmap |
268
272
  | Free and open source | ✅ | ✅ (Allure TestOps is a separate paid product) | ✅ MIT |
269
273
 
@@ -298,6 +302,8 @@ Every option is optional. `npx reporting-labs init` writes them all, with commen
298
302
  | `embedAttachments` | `true` | Screenshots inside the HTML (one file) |
299
303
  | `embedLimit` | 2 MB | Bigger attachments are copied to `./assets` |
300
304
  | `embedVideos` | `false` | Videos inside the HTML too (bigger file, no folder issues) |
305
+ | `emitJson` | `true` | Also write `report.json` alongside `index.html` (used by `merge`) |
306
+ | `jsonFile` | `'report.json'` | File name of the JSON blob |
301
307
  | `embedFonts` | `true` | Bundle the fonts (~140 KB) so it looks the same offline |
302
308
  | `announce` | `true` | Print the report path after the run |
303
309
  | `open` | `'on-failure'` | Open the report in the browser after the run: `'on-failure'`, `'always'` or `'never'`. Never opens in CI |
@@ -326,7 +332,7 @@ Two things to set up:
326
332
  1. **Publish the report.** Upload the `reporting-labs/` folder as a build artifact (or archive it in Jenkins). Videos and large files sit in `reporting-labs/assets/`, so keep the folder together.
327
333
  2. **Keep the history.** `reporting-labs.history.json` powers the trend and the new vs known failures. On GitHub Actions save it with `actions/cache`. On Jenkins the workspace usually keeps it on its own.
328
334
 
329
- Ready-to-copy samples: [docs/ci/github-actions.yml](https://github.com/naveenautomationlabs/reporting-labs/blob/main/docs/ci/github-actions.yml) and [docs/ci/Jenkinsfile](https://github.com/naveenautomationlabs/reporting-labs/blob/main/docs/ci/Jenkinsfile).
335
+ Ready-to-copy samples: [github-actions.yml](https://github.com/naveenautomationlabs/reporting-labs/blob/main/docs/ci/github-actions.yml), [Jenkinsfile](https://github.com/naveenautomationlabs/reporting-labs/blob/main/docs/ci/Jenkinsfile) and [gitlab-ci.yml](https://github.com/naveenautomationlabs/reporting-labs/blob/main/docs/ci/gitlab-ci.yml). Each one includes an optional Slack (and, for Jenkins, email) step you can uncomment.
330
336
 
331
337
  ```yaml
332
338
  # GitHub Actions, the two steps that matter
@@ -344,6 +350,230 @@ Ready-to-copy samples: [docs/ci/github-actions.yml](https://github.com/naveenaut
344
350
 
345
351
  **Jenkins note.** Jenkins blocks inline JavaScript by default, so a single-file report shows up blank inside the Jenkins HTML Publisher (Playwright's own HTML report has the same issue). Download the archived artifact and open it locally, or ask an admin to relax the policy in the script console: `System.setProperty("hudson.model.DirectoryBrowserSupport.CSP", "")`.
346
352
 
353
+ ### Split your run across shards, then merge into one report
354
+
355
+ Big test suite? Split it into shards so they run in parallel, then join every shard's report into one HTML at the end. Works on your laptop and on any CI.
356
+
357
+ **What happens under the hood.** Each shard runs the reporter and writes its own `reporting-labs/` folder with `index.html` plus a small `report.json` next to it. The `merge` command reads every shard's `report.json`, combines them into one dataset, copies attachments into per-shard subfolders so nothing overwrites, and renders one HTML with all tests, one Trend chart, one Failure Clusters view, and a Shards row in the Environment card.
358
+
359
+ Nothing extra to install and no config change needed. The reporter writes `report.json` on every run out of the box.
360
+
361
+ #### Try it on your laptop first
362
+
363
+ Three shards, run one after another (on a single machine you cannot really run them in parallel, but this proves the merge works end to end):
364
+
365
+ ```bash
366
+ # Delete leftovers from any previous run
367
+ rm -rf all-shards merged
368
+
369
+ # Run each shard and move its folder aside so the next shard does not overwrite it
370
+ mkdir -p all-shards
371
+ npx playwright test --shard=1/3 && mv reporting-labs all-shards/s1
372
+ npx playwright test --shard=2/3 && mv reporting-labs all-shards/s2
373
+ npx playwright test --shard=3/3 && mv reporting-labs all-shards/s3
374
+
375
+ # One command combines them
376
+ npx reporting-labs merge all-shards -o merged
377
+
378
+ # Open it
379
+ open merged/index.html # macOS
380
+ # start merged/index.html # Windows
381
+ # xdg-open merged/index.html # Linux
382
+ ```
383
+
384
+ Look for this line in each `npx playwright test` output:
385
+
386
+ ```
387
+ reporting-labs: report written to reporting-labs/index.html
388
+ ```
389
+
390
+ That line proves the reporter ran and the folder exists to move. If you do not see it, your `playwright.config.ts` is not wiring `reporting-labs` as a reporter yet — see the [Quick start](#quick-start-2-minutes).
391
+
392
+ **Custom output folder?** If your config has `outputFolder: 'my-report'`, use that name in the move step:
393
+
394
+ ```bash
395
+ mv my-report all-shards/s1
396
+ ```
397
+
398
+ **One-liner for a real parallel run on your laptop.** Runs three shards at once, then merges when all three finish:
399
+
400
+ ```bash
401
+ rm -rf all-shards merged && mkdir -p all-shards
402
+ (npx playwright test --shard=1/3 && mv reporting-labs all-shards/s1) &
403
+ (npx playwright test --shard=2/3 && mv reporting-labs all-shards/s2) &
404
+ (npx playwright test --shard=3/3 && mv reporting-labs all-shards/s3) &
405
+ wait
406
+ npx reporting-labs merge all-shards -o merged
407
+ open merged/index.html
408
+ ```
409
+
410
+ #### GitHub Actions
411
+
412
+ Each shard runs on its own runner in parallel, then a final `merge` job downloads every shard's report and combines them. Copy-paste as `.github/workflows/tests.yml` in your repo:
413
+
414
+ ```yaml
415
+ name: Playwright tests
416
+
417
+ on:
418
+ push:
419
+ branches: [main]
420
+ pull_request:
421
+
422
+ jobs:
423
+ test:
424
+ name: shard ${{ matrix.shard }}/4
425
+ runs-on: ubuntu-latest
426
+ strategy:
427
+ fail-fast: false
428
+ matrix:
429
+ shard: [1, 2, 3, 4]
430
+ steps:
431
+ - uses: actions/checkout@v4
432
+ - uses: actions/setup-node@v4
433
+ with: { node-version: 20, cache: npm }
434
+ - run: npm ci
435
+ - run: npx playwright install --with-deps
436
+
437
+ # Keep the history file across runs so the Trend and new-vs-known
438
+ # failures build up over time. Each shard reads the same file.
439
+ - uses: actions/cache@v4
440
+ with:
441
+ path: reporting-labs.history.json
442
+ key: reporting-labs-history-${{ github.ref_name }}-${{ github.run_id }}
443
+ restore-keys: |
444
+ reporting-labs-history-${{ github.ref_name }}-
445
+
446
+ - run: npx playwright test --shard=${{ matrix.shard }}/4
447
+
448
+ # Save this shard's report so the merge job can pick it up.
449
+ - uses: actions/upload-artifact@v4
450
+ if: always()
451
+ with:
452
+ name: report-shard-${{ matrix.shard }}
453
+ path: reporting-labs/
454
+ retention-days: 30
455
+
456
+ merge:
457
+ name: merge shards into one report
458
+ if: always()
459
+ needs: test
460
+ runs-on: ubuntu-latest
461
+ steps:
462
+ - uses: actions/checkout@v4
463
+ - uses: actions/setup-node@v4
464
+ with: { node-version: 20, cache: npm }
465
+ - run: npm ci
466
+
467
+ # Downloads every 'report-shard-N' artifact under ./all-shards/report-shard-N/
468
+ - uses: actions/download-artifact@v4
469
+ with:
470
+ path: all-shards
471
+ pattern: report-shard-*
472
+
473
+ - run: npx reporting-labs merge all-shards -o merged
474
+
475
+ # This is the artifact your team downloads and opens.
476
+ - uses: actions/upload-artifact@v4
477
+ with:
478
+ name: merged-report
479
+ path: merged/
480
+ retention-days: 30
481
+ ```
482
+
483
+ Download the **merged-report** artifact from the run's summary page and open `index.html` locally.
484
+
485
+ #### Jenkins
486
+
487
+ Four shards run in parallel via a matrix pipeline, then a final stage merges. Copy-paste as `Jenkinsfile`:
488
+
489
+ ```groovy
490
+ pipeline {
491
+ agent any
492
+ options { timestamps() }
493
+
494
+ stages {
495
+ stage('Install') {
496
+ steps {
497
+ sh 'npm ci'
498
+ sh 'npx playwright install --with-deps'
499
+ }
500
+ }
501
+
502
+ stage('Run shards in parallel') {
503
+ matrix {
504
+ axes {
505
+ axis { name 'SHARD'; values '1', '2', '3', '4' }
506
+ }
507
+ stages {
508
+ stage('Test') {
509
+ steps {
510
+ sh "npx playwright test --shard=${SHARD}/4"
511
+ // Save each shard's report so the merge stage can unpack it later.
512
+ stash name: "report-shard-${SHARD}", includes: 'reporting-labs/**'
513
+ }
514
+ }
515
+ }
516
+ }
517
+ }
518
+
519
+ stage('Merge into one report') {
520
+ steps {
521
+ sh 'rm -rf all-shards merged && mkdir -p all-shards'
522
+ script {
523
+ ['1', '2', '3', '4'].each { s ->
524
+ dir("all-shards/shard-${s}") { unstash "report-shard-${s}" }
525
+ }
526
+ }
527
+ // Unstash lands the folder as all-shards/shard-N/reporting-labs/... — flatten it.
528
+ sh '''
529
+ for d in all-shards/shard-*; do
530
+ mv "$d/reporting-labs/"* "$d/"
531
+ rmdir "$d/reporting-labs"
532
+ done
533
+ '''
534
+ sh 'npx reporting-labs merge all-shards -o merged'
535
+ archiveArtifacts artifacts: 'merged/**', allowEmptyArchive: false
536
+ }
537
+ }
538
+ }
539
+ }
540
+ ```
541
+
542
+ Jenkins blocks inline JavaScript inside the HTML Publisher by default (Playwright's own HTML report has the same limitation), so the fastest way to view the merged report is to download the `merged/` folder from the build's artifacts and open `index.html` locally. If a Jenkins admin can relax the policy in the script console with `System.setProperty("hudson.model.DirectoryBrowserSupport.CSP", "")`, you can also add a `publishHTML` step to open the report right from the build page.
543
+
544
+ #### Troubleshooting
545
+
546
+ - **`mv: reporting-labs: No such file or directory`.** The reporter did not run. Check `playwright.config.ts`: `reporter: [['list'], ['reporting-labs', reportingLabs]]`. Without that line, `npx playwright test` does not create the `reporting-labs/` folder.
547
+ - **A shard reports "0 tests".** Playwright shards by spec file by default, so if you have fewer spec files than shards, some shards will be empty. Reduce the shard count, split large specs, or upgrade to Playwright 1.51+ and set `shardingMode: 'round-robin'` for per-test sharding. The merge still works either way.
548
+ - **The Trend chart looks off after merging.** All shards read the same history file at startup, so the current run is recorded once by whichever shard writes last. On CI, cache the history file (see the GitHub Actions example above) so future runs pick it up.
549
+ - **`merge` says "no report.json under ...".** The folder you pointed at does not contain a shard's report. `merge` accepts either individual shard folders (each with `report.json`) or one parent folder that has many shards as subfolders. Check that `report.json` actually exists inside — the reporter writes it on every run unless you set `emitJson: false`.
550
+ - **Attachments broken in the merged report.** Screenshots are usually embedded inline as base64, so they always work. Videos and traces live in `merged/assets/shard-N-of-M/` — the merge rewrites paths so they resolve correctly. If a video does not play, open the merged folder locally (not from a Jenkins URL that strips inline JS).
551
+
552
+ ### Slack, email, Teams: use your CI's own integration
553
+
554
+ reportingLabs deliberately does not build its own Slack or email sender. Every CI already has a first-class integration you can lean on, and it stays out of the way of the report itself:
555
+
556
+ - **GitHub Actions:** `slackapi/slack-github-action` posts a message with the run URL and artifact link. `dawidd6/action-send-mail` handles email. Both are one YAML block, both take a repo secret. See the [github-actions.yml sample](https://github.com/naveenautomationlabs/reporting-labs/blob/main/docs/ci/github-actions.yml).
557
+ - **Jenkins:** the Slack Notification plugin (`slackSend`) and the Email Extension plugin (`emailext`) do the same, plus they can attach `reporting-labs/index.html`. See the [Jenkinsfile sample](https://github.com/naveenautomationlabs/reporting-labs/blob/main/docs/ci/Jenkinsfile).
558
+ - **GitLab CI:** the Slack integration in Project Settings posts pipeline results with no code at all. For a rich message, add a `notify` job with `curl` to your webhook.
559
+ - **CircleCI, Bitbucket, Azure Pipelines:** each has a native Slack orb / task; a plain `curl` to the webhook also works from any shell step.
560
+
561
+ Two reasons this stays outside the reporter:
562
+
563
+ 1. Your admin most likely already set up notifications for build and deploy. The same channel serves test results with no new moving parts.
564
+ 2. Notification delivery (auth, TLS, retries, corporate relays) is a full topic on its own. CI integrations handle it, this reporter stays a single HTML file.
565
+
566
+ If you need a richer machine-readable summary in the same message, add Playwright's own JSON reporter next to reportingLabs and pipe it to your Slack step:
567
+
568
+ ```ts
569
+ reporter: [
570
+ ['reporting-labs', require('./reporting-labs.config').default],
571
+ ['json', { outputFile: 'results.json' }],
572
+ ]
573
+ ```
574
+
575
+ Your Slack step can then read `results.json` for pass / fail counts and top failures.
576
+
347
577
  ## Good to know
348
578
 
349
579
  - **One file.** Screenshots and fonts are inside `index.html`, so it works from an email or a CI artifact. Videos and large files go to `./assets` next to it. Keep the folder together when you share it.
@@ -0,0 +1,101 @@
1
+ #!/usr/bin/env node
2
+ // Emit dist/template.html — the same HTML shell renderHtml produces, but with
3
+ // well-known placeholders for the fields any language port (Java today, more
4
+ // later) needs to inject at render time:
5
+ //
6
+ // __RL_DATA__ — the report data JSON (the only required one)
7
+ // __RL_ACCENT_CSS__ — a :root{--accent:...} block, or empty
8
+ // __RL_CUSTOM_CSS__ — extra CSS the caller wants appended, or empty
9
+ //
10
+ // The placeholders are seeded with distinctive sentinels and then rewritten,
11
+ // so the surrounding CSS/HTML stays exactly what renderHtml produces.
12
+ //
13
+ // Runs AFTER `tsc` (dist/template.js has to exist).
14
+
15
+ 'use strict';
16
+ const fs = require('fs');
17
+ const path = require('path');
18
+
19
+ const { renderHtml } = require('../dist/template');
20
+
21
+ const ACCENT_SENTINEL = '__RL_ACCENT_SENTINEL_1a56db_2026__';
22
+ const CUSTOM_SENTINEL = '/*__RL_CUSTOM_CSS_SENTINEL_2026__*/';
23
+
24
+ const seed = {
25
+ title: '',
26
+ generatedAt: 0,
27
+ startTime: 0,
28
+ duration: 0,
29
+ metadata: {},
30
+ projects: [],
31
+ workers: 1,
32
+ stats: { total: 0, passed: 0, failed: 0, flaky: 0, skipped: 0 },
33
+ tests: [],
34
+ history: [],
35
+ bdd: false,
36
+ rootDir: '',
37
+ env: [],
38
+ runStatus: 'passed',
39
+ globalErrors: [],
40
+ globalOutput: [],
41
+ options: {
42
+ theme: 'auto',
43
+ palette: 'lab',
44
+ embedFonts: true,
45
+ sections: [],
46
+ widgets: {
47
+ overviewCards: true,
48
+ breakdown: true,
49
+ needsAttention: true,
50
+ failureClusters: true,
51
+ trend: true,
52
+ slowest: true,
53
+ env: true,
54
+ },
55
+ dimensions: ['priority', 'severity', 'owner', 'feature'],
56
+ dimensionOrder: {},
57
+ links: {},
58
+ // Seed accent + customCss with sentinels so we know exactly where they
59
+ // ended up in the rendered HTML.
60
+ accent: ACCENT_SENTINEL,
61
+ customCss: CUSTOM_SENTINEL,
62
+ editorLinks: true,
63
+ },
64
+ };
65
+
66
+ let html = renderHtml(seed);
67
+
68
+ // Data payload placeholder.
69
+ const dataRe = /<script id="rl-data" type="application\/json">[^<]*<\/script>/;
70
+ if (!dataRe.test(html)) {
71
+ console.error('build-template: could not find rl-data script tag');
72
+ process.exit(1);
73
+ }
74
+ html = html.replace(
75
+ dataRe,
76
+ '<script id="rl-data" type="application/json">__RL_DATA__</script>'
77
+ );
78
+
79
+ // Accent + customCss placeholders. renderHtml renders:
80
+ // `:root{--accent:${accent}!important}` when accent is truthy,
81
+ // then appends customCss verbatim.
82
+ // Replace those sentinel-anchored spans with named placeholders that hold
83
+ // nothing by default; the port fills them in at render time.
84
+ const accentBlock = `:root{--accent:${ACCENT_SENTINEL}!important}`;
85
+ if (!html.includes(accentBlock)) {
86
+ console.error('build-template: accent sentinel not found in rendered HTML');
87
+ process.exit(1);
88
+ }
89
+ html = html.replace(accentBlock, '__RL_ACCENT_CSS__');
90
+
91
+ if (!html.includes(CUSTOM_SENTINEL)) {
92
+ console.error('build-template: customCss sentinel not found in rendered HTML');
93
+ process.exit(1);
94
+ }
95
+ html = html.replace(CUSTOM_SENTINEL, '__RL_CUSTOM_CSS__');
96
+
97
+ const out = path.join(__dirname, '..', 'dist', 'template.html');
98
+ fs.writeFileSync(out, html);
99
+
100
+ const sizeKb = (Buffer.byteLength(html) / 1024).toFixed(1);
101
+ console.log(`build-template: wrote ${out} (${sizeKb} KB)`);
package/bin/cli.js CHANGED
@@ -5,7 +5,11 @@ const args = process.argv.slice(2);
5
5
  const cmd = args[0];
6
6
  const has = f => args.includes(f);
7
7
 
8
- if (cmd === 'init') {
8
+ if (cmd === 'merge') {
9
+ const { merge, parseArgs } = require('./merge');
10
+ const { dirs, opts } = parseArgs(args.slice(1));
11
+ merge(dirs, opts);
12
+ } else if (cmd === 'init') {
9
13
  const js = has('--js');
10
14
  const file = path.resolve(js ? 'reporting-labs.config.js' : 'reporting-labs.config.ts');
11
15
  if (fs.existsSync(file) && !has('--force')) {
@@ -37,6 +41,7 @@ if (cmd === 'init') {
37
41
  npx reporting-labs init write reporting-labs.config.ts with every option, commented
38
42
  npx reporting-labs init --js same, as reporting-labs.config.js
39
43
  npx reporting-labs init --force overwrite an existing config file
44
+ npx reporting-labs merge <dirs> combine several shard runs into one report (see 'merge --help')
40
45
 
41
46
  Then in playwright.config.ts:
42
47
  import reportingLabs from './reporting-labs.config';
@@ -31,6 +31,7 @@ const config: ReportingLabsOptions = {
31
31
  // ── Output ───────────────────────────────────────────────────────────────────
32
32
  // outputFolder: 'reporting-labs', // where index.html and copied attachments go
33
33
  // outputFile: 'index.html',
34
+ // emitJson: true, // also write report.json (used by `reporting-labs merge` for sharded runs)
34
35
  // embedAttachments: true, // inline screenshots as base64: one file, opens anywhere
35
36
  // embedLimit: 2 * 1024 * 1024, // attachments bigger than this (bytes) are copied as files
36
37
  // embedVideos: false, // true = videos inside the HTML too (bigger file, no folder issues)
package/bin/merge.js ADDED
@@ -0,0 +1,173 @@
1
+ // Merge several shard runs into one report.
2
+ // Usage: node bin/merge.js <shardDir1> <shardDir2> ... [-o <outDir>]
3
+ // (invoked by bin/cli.js as `npx reporting-labs merge ...`)
4
+
5
+ 'use strict';
6
+ const fs = require('fs');
7
+ const path = require('path');
8
+
9
+ function usage(exit = 0) {
10
+ console.log(
11
+ `reporting-labs merge
12
+
13
+ Combine several shard runs into one report.
14
+
15
+ Usage:
16
+ npx reporting-labs merge <shard-dir> [<shard-dir> ...] [--out <dir>] [--file <name>]
17
+
18
+ Arguments:
19
+ <shard-dir> A folder that contains report.json (the shard's reporting-labs output).
20
+ You can also pass a parent folder that contains many shards, e.g.
21
+ "npx reporting-labs merge ./all-shards" where each subfolder is a shard.
22
+
23
+ Options:
24
+ --out, -o <dir> Where the merged report is written. Default: reporting-labs-merged
25
+ --file <name> Output HTML file name. Default: index.html
26
+ --title <text> Override the report title. Default: the first shard's title
27
+ --help, -h Show this message
28
+
29
+ Example (GitHub Actions):
30
+ # each shard uploads its own reporting-labs/ folder as an artifact,
31
+ # a final job downloads them all under ./all-shards/, then:
32
+ npx reporting-labs merge ./all-shards -o merged/`);
33
+ process.exit(exit);
34
+ }
35
+
36
+ function parseArgs(argv) {
37
+ const dirs = [];
38
+ const opts = { out: 'reporting-labs-merged', file: 'index.html', title: undefined };
39
+ for (let i = 0; i < argv.length; i++) {
40
+ const a = argv[i];
41
+ if (a === '--help' || a === '-h') usage(0);
42
+ else if (a === '--out' || a === '-o') opts.out = argv[++i];
43
+ else if (a === '--file') opts.file = argv[++i];
44
+ else if (a === '--title') opts.title = argv[++i];
45
+ else if (a.startsWith('-')) { console.error('unknown flag: ' + a); usage(1); }
46
+ else dirs.push(a);
47
+ }
48
+ if (!dirs.length) usage(1);
49
+ return { dirs, opts };
50
+ }
51
+
52
+ /** Expand each arg: if the arg is a shard dir (has report.json) keep it; otherwise treat as a parent and expand its immediate subdirs that have report.json. */
53
+ function resolveShardDirs(inputs) {
54
+ const out = [];
55
+ for (const p of inputs) {
56
+ const abs = path.resolve(p);
57
+ if (!fs.existsSync(abs)) { console.error('missing:', abs); process.exit(1); }
58
+ if (fs.existsSync(path.join(abs, 'report.json'))) out.push(abs);
59
+ else {
60
+ const kids = fs.readdirSync(abs).map(k => path.join(abs, k)).filter(k => fs.statSync(k).isDirectory() && fs.existsSync(path.join(k, 'report.json')));
61
+ if (!kids.length) { console.error(`no report.json under ${abs}, nor in any subfolder`); process.exit(1); }
62
+ out.push(...kids);
63
+ }
64
+ }
65
+ return out;
66
+ }
67
+
68
+ function rewriteAttachmentPath(a, prefix) {
69
+ if (!a) return a;
70
+ // The reporter emits 'src' pointing at 'assets/<name>' for file-backed attachments;
71
+ // namespace it under the shard's folder so multiple shards can share one merged assets/.
72
+ const src = a.src;
73
+ if (typeof src === 'string' && src.startsWith('assets/')) return { ...a, src: 'assets/' + prefix + '/' + src.slice('assets/'.length) };
74
+ return a;
75
+ }
76
+
77
+ function copyDir(from, to) {
78
+ if (!fs.existsSync(from)) return;
79
+ fs.mkdirSync(to, { recursive: true });
80
+ for (const entry of fs.readdirSync(from, { withFileTypes: true })) {
81
+ const src = path.join(from, entry.name), dst = path.join(to, entry.name);
82
+ if (entry.isDirectory()) copyDir(src, dst);
83
+ else fs.copyFileSync(src, dst);
84
+ }
85
+ }
86
+
87
+ function shardLabel(dir, i, data) {
88
+ if (data && data.shard && data.shard.total) return `shard-${data.shard.current}-of-${data.shard.total}`;
89
+ return `shard-${i + 1}`;
90
+ }
91
+
92
+ function merge(inputs, opts) {
93
+ const dirs = resolveShardDirs(inputs);
94
+ console.log(`Merging ${dirs.length} shard${dirs.length === 1 ? '' : 's'} into ${opts.out}`);
95
+
96
+ const shards = dirs.map((d, i) => {
97
+ const data = JSON.parse(fs.readFileSync(path.join(d, 'report.json'), 'utf8'));
98
+ return { dir: d, label: shardLabel(d, i, data), data };
99
+ });
100
+
101
+ const first = shards[0].data;
102
+ // Compose combined data. Start from the first shard's options/env, then merge in test rows.
103
+ const combined = {
104
+ ...first,
105
+ title: opts.title || first.title,
106
+ startTime: Math.min(...shards.map(s => s.data.startTime || 0)),
107
+ duration: Math.max(...shards.map(s => s.data.duration || 0)),
108
+ generatedAt: Date.now(),
109
+ stats: { passed: 0, failed: 0, skipped: 0, flaky: 0, timedOut: 0, interrupted: 0, total: 0 },
110
+ tests: [],
111
+ globalErrors: [],
112
+ globalOutput: [],
113
+ projects: [],
114
+ workers: shards.reduce((n, s) => n + (s.data.workers || 0), 0),
115
+ shard: undefined, // no longer sharded
116
+ env: [...(first.env || [])],
117
+ history: first.history || [], // all shards share the same history file
118
+ };
119
+
120
+ const projSet = new Set();
121
+ for (const s of shards) {
122
+ const stats = s.data.stats || {};
123
+ for (const k of Object.keys(combined.stats)) combined.stats[k] += stats[k] || 0;
124
+ (s.data.projects || []).forEach(p => projSet.add(p));
125
+
126
+ for (const t of (s.data.tests || [])) {
127
+ const nt = {
128
+ ...t,
129
+ results: (t.results || []).map(r => ({
130
+ ...r,
131
+ attachments: (r.attachments || []).map(a => rewriteAttachmentPath(a, s.label)),
132
+ })),
133
+ };
134
+ combined.tests.push(nt);
135
+ }
136
+ for (const e of (s.data.globalErrors || [])) combined.globalErrors.push(e);
137
+ for (const o of (s.data.globalOutput || [])) combined.globalOutput.push(o);
138
+ }
139
+ combined.projects = [...projSet].sort();
140
+ combined.env.push({ k: 'Shards', v: shards.map(s => s.label).join(', ') });
141
+ if (combined.runStatus === undefined || combined.runStatus === 'passed') {
142
+ const anyFail = shards.some(s => s.data.runStatus && s.data.runStatus !== 'passed');
143
+ if (anyFail) combined.runStatus = 'failed';
144
+ }
145
+
146
+ // Prepare output folder
147
+ fs.mkdirSync(opts.out, { recursive: true });
148
+ const assetsRoot = path.join(opts.out, 'assets');
149
+ fs.rmSync(assetsRoot, { recursive: true, force: true });
150
+
151
+ for (const s of shards) {
152
+ const src = path.join(s.dir, 'assets');
153
+ if (fs.existsSync(src)) copyDir(src, path.join(assetsRoot, s.label));
154
+ }
155
+
156
+ // Render combined HTML using the reporter's own template.
157
+ const { renderHtml } = require(path.resolve(__dirname, '..', 'dist', 'template.js'));
158
+ const outFile = path.join(opts.out, opts.file);
159
+ fs.writeFileSync(outFile, renderHtml(combined), 'utf8');
160
+ fs.writeFileSync(path.join(opts.out, 'report.json'), JSON.stringify(combined), 'utf8');
161
+
162
+ const st = combined.stats;
163
+ const rel = path.relative(process.cwd(), outFile);
164
+ console.log(` merged: ${st.total} tests · ${st.passed} passed · ${st.failed + st.timedOut + st.interrupted} failed · ${st.flaky} flaky · ${st.skipped} skipped`);
165
+ console.log(` written to ${rel}`);
166
+ }
167
+
168
+ module.exports = { merge, parseArgs };
169
+
170
+ if (require.main === module) {
171
+ const { dirs, opts } = parseArgs(process.argv.slice(2));
172
+ merge(dirs, opts);
173
+ }
@@ -24,7 +24,8 @@ export default class ReportingLabsReporter implements Reporter {
24
24
  private printMissingMeta;
25
25
  /** Open the report in the default browser, like Playwright's HTML reporter. Never in CI. */
26
26
  private maybeOpen;
27
- /** Videos and traces are finalized asynchronously by the runner; wait until every file-backed attachment stops growing. */
27
+ /** Videos and traces are finalized asynchronously by the runner; wait until every file-backed attachment stops growing.
28
+ * All files are polled in parallel so total wait is bounded by the slowest single file, not the sum. */
28
29
  private settleAttachmentFiles;
29
30
  /** Runtime facts for the Environment card: Playwright, Node, OS, browsers, CI job, git commit. */
30
31
  private collectEnv;
package/dist/reporter.js CHANGED
@@ -202,6 +202,10 @@ class ReportingLabsReporter {
202
202
  };
203
203
  const file = path.join(this.outDir, this.options.outputFile ?? 'index.html');
204
204
  fs.writeFileSync(file, (0, template_1.renderHtml)(data), 'utf8');
205
+ if (this.options.emitJson !== false) {
206
+ const jsonFile = path.join(this.outDir, this.options.jsonFile ?? 'report.json');
207
+ fs.writeFileSync(jsonFile, JSON.stringify(data), 'utf8');
208
+ }
205
209
  if (this.options.announce !== false) {
206
210
  const rel = path.relative(process.cwd(), file);
207
211
  console.log(`\n reporting-labs: report written to ${rel}`);
@@ -246,7 +250,8 @@ class ReportingLabsReporter {
246
250
  catch { /* ignore */ }
247
251
  }
248
252
  // ---- helpers -------------------------------------------------------------
249
- /** Videos and traces are finalized asynchronously by the runner; wait until every file-backed attachment stops growing. */
253
+ /** Videos and traces are finalized asynchronously by the runner; wait until every file-backed attachment stops growing.
254
+ * All files are polled in parallel so total wait is bounded by the slowest single file, not the sum. */
250
255
  async settleAttachmentFiles() {
251
256
  const paths = new Set();
252
257
  for (const test of this.suite.allTests())
@@ -254,6 +259,8 @@ class ReportingLabsReporter {
254
259
  for (const a of r.attachments)
255
260
  if (a.path)
256
261
  paths.add(a.path);
262
+ if (!paths.size)
263
+ return;
257
264
  const size = (p) => { try {
258
265
  return fs.statSync(p).size;
259
266
  }
@@ -261,18 +268,19 @@ class ReportingLabsReporter {
261
268
  return -1;
262
269
  } };
263
270
  const sleep = (ms) => new Promise(r => setTimeout(r, ms));
264
- for (const p of paths) {
271
+ const wait = async (p) => {
265
272
  let last = size(p);
273
+ if (last < 0)
274
+ return; // never appeared; nothing to wait for
266
275
  for (let i = 0; i < 20; i++) { // up to ~5 s per file
267
- if (last < 0)
268
- break; // not on disk (yet); nothing to wait for
269
276
  await sleep(250);
270
277
  const now = size(p);
271
278
  if (now === last && now > 0)
272
- break;
279
+ return; // stable and non-empty: settled
273
280
  last = now;
274
281
  }
275
- }
282
+ };
283
+ await Promise.all([...paths].map(wait));
276
284
  }
277
285
  /** Runtime facts for the Environment card: Playwright, Node, OS, browsers, CI job, git commit. */
278
286
  collectEnv(base) {