reporting-labs 0.5.0 → 0.6.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/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)
@@ -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,94 @@ 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? Run it faster by splitting it into shards. Each shard is a separate CI job, all running at the same time. When they finish, join them into one report.
356
+
357
+ **How it works:**
358
+
359
+ 1. Each shard runs your tests. Each one writes its own `reporting-labs/index.html` and a small `report.json` next to it. Save each shard's folder as a CI artifact.
360
+ 2. In one last job, download all the shard folders and run one command:
361
+
362
+ ```bash
363
+ npx reporting-labs merge ./all-shards -o merged/
364
+ ```
365
+
366
+ You get one report with:
367
+
368
+ - All tests from every shard in one list, sorted by priority
369
+ - Combined pass / fail / flaky numbers on top
370
+ - One Trend chart, one Environment card, one Failure Clusters view
371
+ - Screenshots and videos kept in `merged/assets/shard-1-of-4/`, `merged/assets/shard-2-of-4/`, so nothing overwrites
372
+
373
+ **GitHub Actions example** (replace `npx playwright test` with your own command):
374
+
375
+ ```yaml
376
+ jobs:
377
+ test:
378
+ strategy:
379
+ matrix: { shard: [1, 2, 3, 4] }
380
+ runs-on: ubuntu-latest
381
+ steps:
382
+ - uses: actions/checkout@v4
383
+ - uses: actions/setup-node@v4
384
+ with: { node-version: 20, cache: npm }
385
+ - run: npm ci
386
+ - run: npx playwright install --with-deps
387
+ - run: npx playwright test --shard=${{ matrix.shard }}/4
388
+ - uses: actions/upload-artifact@v4
389
+ if: always()
390
+ with:
391
+ name: report-shard-${{ matrix.shard }}
392
+ path: reporting-labs/
393
+ retention-days: 30
394
+
395
+ merge:
396
+ if: always()
397
+ needs: test
398
+ runs-on: ubuntu-latest
399
+ steps:
400
+ - uses: actions/checkout@v4
401
+ - uses: actions/setup-node@v4
402
+ with: { node-version: 20, cache: npm }
403
+ - run: npm ci
404
+ - uses: actions/download-artifact@v4
405
+ with: { path: all-shards, pattern: 'report-shard-*' }
406
+ - run: npx reporting-labs merge all-shards -o merged/
407
+ - uses: actions/upload-artifact@v4
408
+ with: { name: merged-report, path: merged/, retention-days: 30 }
409
+ ```
410
+
411
+ **Notes:**
412
+
413
+ - The reporter always writes `report.json` alongside `index.html`, so `merge` just works. Turn it off with `emitJson: false` if you do not want it.
414
+ - Not using shards? Ignore this section. The single-run report keeps working exactly as before.
415
+
416
+ ### Slack, email, Teams: use your CI's own integration
417
+
418
+ 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:
419
+
420
+ - **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).
421
+ - **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).
422
+ - **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.
423
+ - **CircleCI, Bitbucket, Azure Pipelines:** each has a native Slack orb / task; a plain `curl` to the webhook also works from any shell step.
424
+
425
+ Two reasons this stays outside the reporter:
426
+
427
+ 1. Your admin most likely already set up notifications for build and deploy. The same channel serves test results with no new moving parts.
428
+ 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.
429
+
430
+ 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:
431
+
432
+ ```ts
433
+ reporter: [
434
+ ['reporting-labs', require('./reporting-labs.config').default],
435
+ ['json', { outputFile: 'results.json' }],
436
+ ]
437
+ ```
438
+
439
+ Your Slack step can then read `results.json` for pass / fail counts and top failures.
440
+
347
441
  ## Good to know
348
442
 
349
443
  - **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.
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) {
package/dist/types.d.ts CHANGED
@@ -13,6 +13,10 @@ export interface ReportingLabsOptions {
13
13
  outputFolder?: string;
14
14
  /** Output HTML file name inside outputFolder. Default: "index.html" */
15
15
  outputFile?: string;
16
+ /** Also write a machine-readable `report.json` alongside `index.html`. Used by `reporting-labs merge` to combine shard runs. Default: true. */
17
+ emitJson?: boolean;
18
+ /** File name of the JSON blob inside outputFolder. Default: "report.json" */
19
+ jsonFile?: string;
16
20
  /** Inline screenshots as base64 (single file, opens anywhere). Default: true */
17
21
  embedAttachments?: boolean;
18
22
  /** Max size (bytes) of a single attachment to embed. Larger ones are copied as files. Default: 2 MB */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reporting-labs",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "reportingLabs – beautiful, customizable single-file HTML test reports. One line of config. Playwright adapter included, more runners on the way.",
5
5
  "keywords": [
6
6
  "test-report",