reporting-labs 0.6.0 → 0.6.2

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
@@ -88,7 +88,28 @@ One line per test. With this the report can rank failures by priority, group the
88
88
 
89
89
  - Known keys: `priority`, `severity`, `owner`, `feature`, `epic`, `story`, `issue`, `component`, `team`. Any other key you pass is shown too.
90
90
  - Your Playwright tags like `@sanity` or `@regression` stay as they are and still show on the test.
91
- - To make story and epic keys clickable, set `links` in the config: `links: { story: 'https://yourteam.atlassian.net/browse/{id}' }`.
91
+ - To make story and epic keys clickable, set `links` in the config: `links: { story: 'https://yourteam.atlassian.net/browse/{id}' }`. Several ids (`story: ['SHOP-1', 'SHOP-2']`) become one link each.
92
+ - A link whose URL needs more than the shown value (a workspace, a project, an organisation) takes an object. Any tool, any URL shape: every `{placeholder}` in `url` is filled from the object you pass in `meta()`, `display` is what the report shows (default `{id}`), the other fields only build the URL. ALM Octane / ValueEdge as an example:
93
+
94
+ ```ts
95
+ // reporting-labs.config.ts
96
+ links: {
97
+ octaneTestCase: {
98
+ url: 'https://oss.valueedge.com/ui/?p={p}#/entity-navigation?entityType=test&id={id}',
99
+ display: '{id}',
100
+ },
101
+ },
102
+ ```
103
+
104
+ ```ts
105
+ // tests/notifications.spec.ts
106
+ test('TC003 - manual trigger with all notifications disabled', async ({ page }) => {
107
+ meta({ priority: 'P1', owner: 'chetan', octaneTestCase: { id: '58966', p: '4001/14014' } });
108
+ // ... your test as usual
109
+ });
110
+ ```
111
+
112
+ The test shows a chip **octaneTestCase 58966**; clicking it opens the full URL. `p` never appears in the report and can differ per test. The same shape covers Azure DevOps (`url: 'https://dev.azure.com/{org}/{project}/_workitems/edit/{id}', display: 'AB#{id}'`) or any in-house tool. Jira, TestRail, Xray and Zephyr need only the plain `{id}` string.
92
113
  - Forgot one? After every run the console lists the tests that have no `meta()`, with file and line. Turn it off with `warnMissingMeta: false`.
93
114
  - `priority`, `severity`, `feature` and `owner` each get a tab in the Breakdown chart and a filter on the Tests page. Want the same for your own key, say `meta({ team: 'web' })`? Add it to `dimensions` in the config: `dimensions: ['priority', 'severity', 'feature', 'owner', 'team']`.
94
115
 
@@ -267,7 +288,7 @@ Legend: ✅ built in · 🟡 possible with manual setup or extra config · ❌ n
267
288
  | Secrets masked | ❌ | 🟡 parameter masking | ✅ automatic |
268
289
  | CSV / JSON export of failures, Slack summary | ❌ | ❌ | ✅ |
269
290
  | Timeline by worker | ❌ | ✅ | ✅ |
270
- | Combine shards / several runs | ✅ blob + `merge-reports` | ✅ Launches | ❌ on the roadmap |
291
+ | Combine shards / several runs | ✅ blob + `merge-reports` | ✅ Launches | ✅ `reporting-labs merge` |
271
292
  | Frameworks beyond Playwright | ❌ | ✅ many languages | ❌ on the roadmap |
272
293
  | Free and open source | ✅ | ✅ (Allure TestOps is a separate paid product) | ✅ MIT |
273
294
 
@@ -284,7 +305,7 @@ Every option is optional. `npx reporting-labs init` writes them all, with commen
284
305
  | `project` | – | `{ name, version, team, url }` shown under the title |
285
306
  | `metadata` | `{}` | Chips in the header, e.g. `{ env: 'staging', build: '#1842' }`. `build` labels the run in the trend; in CI the run number is used when it is not set |
286
307
  | `env` | – | Extra rows on the Environment card |
287
- | `links` | `{}` | Turn meta keys into links. `{id}` is replaced by the value |
308
+ | `links` | `{}` | Turn meta keys into links. `{id}` is replaced by the value. An object `{ url, display }` builds the URL from several fields of an object passed to `meta()`, see below |
288
309
  | `maskKeys` | `[]` | Extra keys to mask as `****` |
289
310
  | `dimensions` | `['priority','severity','feature','owner']` | Which `meta()` keys get a tab in the Breakdown chart and a dropdown filter on the Tests page. Add your own key, e.g. `'team'`, to get a chart for it |
290
311
  | `dimensionOrder` | P0…P4, blocker…trivial | The order values appear in those charts and filters. Only needed for your own values, e.g. `{ severity: ['high','medium','low'] }` |
@@ -352,39 +373,100 @@ Ready-to-copy samples: [github-actions.yml](https://github.com/naveenautomationl
352
373
 
353
374
  ### Split your run across shards, then merge into one report
354
375
 
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.
376
+ 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
377
 
357
- **How it works:**
378
+ **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
379
 
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:
380
+ Nothing extra to install and no config change needed. The reporter writes `report.json` on every run out of the box.
381
+
382
+ #### Try it on your laptop first
383
+
384
+ 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):
385
+
386
+ ```bash
387
+ # Delete leftovers from any previous run
388
+ rm -rf all-shards merged
389
+
390
+ # Run each shard and move its folder aside so the next shard does not overwrite it
391
+ mkdir -p all-shards
392
+ npx playwright test --shard=1/3 && mv reporting-labs all-shards/s1
393
+ npx playwright test --shard=2/3 && mv reporting-labs all-shards/s2
394
+ npx playwright test --shard=3/3 && mv reporting-labs all-shards/s3
395
+
396
+ # One command combines them
397
+ npx reporting-labs merge all-shards -o merged
398
+
399
+ # Open it
400
+ open merged/index.html # macOS
401
+ # start merged/index.html # Windows
402
+ # xdg-open merged/index.html # Linux
403
+ ```
404
+
405
+ Look for this line in each `npx playwright test` output:
406
+
407
+ ```
408
+ reporting-labs: report written to reporting-labs/index.html
409
+ ```
410
+
411
+ 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).
412
+
413
+ **Custom output folder?** If your config has `outputFolder: 'my-report'`, use that name in the move step:
361
414
 
362
415
  ```bash
363
- npx reporting-labs merge ./all-shards -o merged/
416
+ mv my-report all-shards/s1
364
417
  ```
365
418
 
366
- You get one report with:
419
+ **One-liner for a real parallel run on your laptop.** Runs three shards at once, then merges when all three finish:
420
+
421
+ ```bash
422
+ rm -rf all-shards merged && mkdir -p all-shards
423
+ (npx playwright test --shard=1/3 && mv reporting-labs all-shards/s1) &
424
+ (npx playwright test --shard=2/3 && mv reporting-labs all-shards/s2) &
425
+ (npx playwright test --shard=3/3 && mv reporting-labs all-shards/s3) &
426
+ wait
427
+ npx reporting-labs merge all-shards -o merged
428
+ open merged/index.html
429
+ ```
367
430
 
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
431
+ #### GitHub Actions
372
432
 
373
- **GitHub Actions example** (replace `npx playwright test` with your own command):
433
+ 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:
374
434
 
375
435
  ```yaml
436
+ name: Playwright tests
437
+
438
+ on:
439
+ push:
440
+ branches: [main]
441
+ pull_request:
442
+
376
443
  jobs:
377
444
  test:
378
- strategy:
379
- matrix: { shard: [1, 2, 3, 4] }
445
+ name: shard ${{ matrix.shard }}/4
380
446
  runs-on: ubuntu-latest
447
+ strategy:
448
+ fail-fast: false
449
+ matrix:
450
+ shard: [1, 2, 3, 4]
381
451
  steps:
382
452
  - uses: actions/checkout@v4
383
453
  - uses: actions/setup-node@v4
384
454
  with: { node-version: 20, cache: npm }
385
455
  - run: npm ci
386
456
  - run: npx playwright install --with-deps
457
+
458
+ # Keep the history file across runs so the Trend and new-vs-known
459
+ # failures build up over time. Each shard reads the same file.
460
+ - uses: actions/cache@v4
461
+ with:
462
+ path: reporting-labs.history.json
463
+ key: reporting-labs-history-${{ github.ref_name }}-${{ github.run_id }}
464
+ restore-keys: |
465
+ reporting-labs-history-${{ github.ref_name }}-
466
+
387
467
  - run: npx playwright test --shard=${{ matrix.shard }}/4
468
+
469
+ # Save this shard's report so the merge job can pick it up.
388
470
  - uses: actions/upload-artifact@v4
389
471
  if: always()
390
472
  with:
@@ -393,6 +475,7 @@ jobs:
393
475
  retention-days: 30
394
476
 
395
477
  merge:
478
+ name: merge shards into one report
396
479
  if: always()
397
480
  needs: test
398
481
  runs-on: ubuntu-latest
@@ -401,17 +484,91 @@ jobs:
401
484
  - uses: actions/setup-node@v4
402
485
  with: { node-version: 20, cache: npm }
403
486
  - run: npm ci
487
+
488
+ # Downloads every 'report-shard-N' artifact under ./all-shards/report-shard-N/
404
489
  - uses: actions/download-artifact@v4
405
- with: { path: all-shards, pattern: 'report-shard-*' }
406
- - run: npx reporting-labs merge all-shards -o merged/
490
+ with:
491
+ path: all-shards
492
+ pattern: report-shard-*
493
+
494
+ - run: npx reporting-labs merge all-shards -o merged
495
+
496
+ # This is the artifact your team downloads and opens.
407
497
  - uses: actions/upload-artifact@v4
408
- with: { name: merged-report, path: merged/, retention-days: 30 }
498
+ with:
499
+ name: merged-report
500
+ path: merged/
501
+ retention-days: 30
409
502
  ```
410
503
 
411
- **Notes:**
504
+ Download the **merged-report** artifact from the run's summary page and open `index.html` locally.
505
+
506
+ #### Jenkins
507
+
508
+ Four shards run in parallel via a matrix pipeline, then a final stage merges. Copy-paste as `Jenkinsfile`:
509
+
510
+ ```groovy
511
+ pipeline {
512
+ agent any
513
+ options { timestamps() }
514
+
515
+ stages {
516
+ stage('Install') {
517
+ steps {
518
+ sh 'npm ci'
519
+ sh 'npx playwright install --with-deps'
520
+ }
521
+ }
522
+
523
+ stage('Run shards in parallel') {
524
+ matrix {
525
+ axes {
526
+ axis { name 'SHARD'; values '1', '2', '3', '4' }
527
+ }
528
+ stages {
529
+ stage('Test') {
530
+ steps {
531
+ sh "npx playwright test --shard=${SHARD}/4"
532
+ // Save each shard's report so the merge stage can unpack it later.
533
+ stash name: "report-shard-${SHARD}", includes: 'reporting-labs/**'
534
+ }
535
+ }
536
+ }
537
+ }
538
+ }
539
+
540
+ stage('Merge into one report') {
541
+ steps {
542
+ sh 'rm -rf all-shards merged && mkdir -p all-shards'
543
+ script {
544
+ ['1', '2', '3', '4'].each { s ->
545
+ dir("all-shards/shard-${s}") { unstash "report-shard-${s}" }
546
+ }
547
+ }
548
+ // Unstash lands the folder as all-shards/shard-N/reporting-labs/... — flatten it.
549
+ sh '''
550
+ for d in all-shards/shard-*; do
551
+ mv "$d/reporting-labs/"* "$d/"
552
+ rmdir "$d/reporting-labs"
553
+ done
554
+ '''
555
+ sh 'npx reporting-labs merge all-shards -o merged'
556
+ archiveArtifacts artifacts: 'merged/**', allowEmptyArchive: false
557
+ }
558
+ }
559
+ }
560
+ }
561
+ ```
562
+
563
+ 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.
564
+
565
+ #### Troubleshooting
412
566
 
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.
567
+ - **`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.
568
+ - **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.
569
+ - **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.
570
+ - **`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`.
571
+ - **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).
415
572
 
416
573
  ### Slack, email, Teams: use your CI's own integration
417
574
 
@@ -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/dist/auto.js CHANGED
@@ -154,28 +154,65 @@ function patchContext(ctx) {
154
154
  };
155
155
  proto[PATCHED] = true;
156
156
  }
157
+ /** Wrap `obj[name]` (found on the prototype chain) so `after(result)` sees each resolved return value. */
158
+ function wrapAsync(obj, name, after) {
159
+ let proto = obj;
160
+ while (proto && !Object.prototype.hasOwnProperty.call(proto, name))
161
+ proto = Object.getPrototypeOf(proto);
162
+ if (!proto || typeof proto[name] !== 'function' || proto[name][PATCHED])
163
+ return;
164
+ const original = proto[name];
165
+ const wrapped = async function (...args) {
166
+ const result = await original.apply(this, args);
167
+ try {
168
+ after(result);
169
+ }
170
+ catch { /* never break the caller */ }
171
+ return result;
172
+ };
173
+ wrapped[PATCHED] = true;
174
+ proto[name] = wrapped;
175
+ }
176
+ /**
177
+ * Hook the places an APIRequestContext can come from, and patch the shared prototype the
178
+ * first time one shows up:
179
+ * - playwright.request.newContext() (the `request` fixture, manual contexts)
180
+ * - browser.newContext() / launchPersistentContext() / connect*() (page.request, context.request)
181
+ *
182
+ * No context is created here. An earlier version made a throwaway context at import time and
183
+ * disposed it; when that dispose raced with the test runner's tracing bookkeeping the runner
184
+ * tried to start a trace on a closed context and every test failed with
185
+ * "apiRequestContext._wrapApiCall: Target page, context or browser has been closed".
186
+ */
157
187
  function install() {
158
188
  let api;
159
189
  try {
160
- api = pw().request;
190
+ api = pw();
161
191
  }
162
192
  catch {
163
193
  return;
164
194
  }
165
- if (!api || typeof api.newContext !== 'function')
166
- return;
167
- const apiProto = Object.getPrototypeOf(api);
168
- if (apiProto[PATCHED])
169
- return;
170
- apiProto[PATCHED] = true;
171
- // Any context created later through request.newContext() patches the shared prototype on the spot.
172
- const newContext = apiProto.newContext;
173
- apiProto.newContext = async function patchedNewContext(...args) {
174
- const ctx = await newContext.apply(this, args);
175
- patchContext(ctx);
176
- return ctx;
195
+ const { request, chromium, firefox, webkit } = api;
196
+ if (request && typeof request.newContext === 'function')
197
+ wrapAsync(request, 'newContext', ctx => patchContext(ctx));
198
+ const onBrowser = (browser) => {
199
+ if (!browser || typeof browser.newContext !== 'function')
200
+ return;
201
+ wrapAsync(browser, 'newContext', ctx => ctx?.request && patchContext(ctx.request));
202
+ try {
203
+ for (const ctx of browser.contexts())
204
+ if (ctx?.request)
205
+ patchContext(ctx.request);
206
+ }
207
+ catch { /* not a browser */ }
177
208
  };
178
- // And do it right away with a throwaway context, so page.request works even if no request fixture is ever created.
179
- api.newContext().then((ctx) => { patchContext(ctx); return ctx.dispose(); }).catch(() => { });
209
+ for (const bt of [chromium, firefox, webkit]) {
210
+ if (!bt)
211
+ continue;
212
+ wrapAsync(bt, 'launch', onBrowser);
213
+ wrapAsync(bt, 'connect', onBrowser);
214
+ wrapAsync(bt, 'connectOverCDP', onBrowser);
215
+ wrapAsync(bt, 'launchPersistentContext', ctx => ctx?.request && patchContext(ctx.request));
216
+ }
180
217
  }
181
218
  install();
package/dist/meta.d.ts CHANGED
@@ -4,6 +4,9 @@ import type { ApiCall, TestMeta } from './types';
4
4
  *
5
5
  * @example
6
6
  * meta({ priority: 'P1', severity: 'critical', owner: 'naveen', feature: 'checkout', story: 'SHOP-231' });
7
+ *
8
+ * A value can be an object when the link needs more than one parameter (see `links` in the config):
9
+ * meta({ octaneTestCase: { id: '58966', p: '4001/14014' } });
7
10
  */
8
11
  export declare function meta(values: TestMeta): void;
9
12
  /**
package/dist/meta.js CHANGED
@@ -16,13 +16,19 @@ function info() {
16
16
  *
17
17
  * @example
18
18
  * meta({ priority: 'P1', severity: 'critical', owner: 'naveen', feature: 'checkout', story: 'SHOP-231' });
19
+ *
20
+ * A value can be an object when the link needs more than one parameter (see `links` in the config):
21
+ * meta({ octaneTestCase: { id: '58966', p: '4001/14014' } });
19
22
  */
20
23
  function meta(values) {
21
24
  const i = info();
22
25
  for (const [k, v] of Object.entries(values)) {
23
26
  if (v === undefined)
24
27
  continue;
25
- i.annotations.push({ type: k.toLowerCase(), description: String(v) });
28
+ // Objects (e.g. { id: '58966', p: '4001/14014' } for a multi-parameter link) travel as JSON.
29
+ // Arrays (story: ['SHOP-1', 'SHOP-2']) become one chip per value; objects (a multi-parameter link) travel as JSON.
30
+ const text = Array.isArray(v) ? v.map(String).join(', ') : v !== null && typeof v === 'object' ? JSON.stringify(v) : String(v);
31
+ i.annotations.push({ type: k.toLowerCase(), description: text });
26
32
  }
27
33
  }
28
34
  /**
@@ -34,7 +34,15 @@ export default class ReportingLabsReporter implements Reporter {
34
34
  private dimensions;
35
35
  /** Keys picked up from tags and annotations even when they are not breakdown dimensions. */
36
36
  private metaKeys;
37
- /** Pull meta values (priority, owner, story, epic...) from annotations and tags. */
37
+ /** The link template for a (lower-cased) meta key, falling back to '*'. */
38
+ private linkTemplate;
39
+ /** `links` reduced to URL templates, the shape the template expects. */
40
+ private linkUrls;
41
+ /**
42
+ * Pull meta values (priority, owner, story, epic...) from annotations and tags.
43
+ * An object value (meta({ octaneTestCase: { id, p } })) is folded to its display text and, when the key has a
44
+ * link template, to a ready-made href in `links`; the other fields never show in the report.
45
+ */
38
46
  private extractMeta;
39
47
  /** A local image file (path relative to the config) is embedded as a data URI so the report stays self-contained. URLs and data URIs pass through. */
40
48
  private resolveLogo;
package/dist/reporter.js CHANGED
@@ -105,6 +105,7 @@ class ReportingLabsReporter {
105
105
  const last = test.results[test.results.length - 1];
106
106
  const resultAnn = (last?.annotations ?? []);
107
107
  const annotations = [...test.annotations, ...resultAnn.filter(a => !test.annotations.some(b => b.type === a.type && b.description === a.description))];
108
+ const extracted = this.extractMeta(test);
108
109
  tests.push({
109
110
  id: test.id,
110
111
  key: `${project}::${file}::${[...titlePath, test.title].join(' › ')}${test.repeatEachIndex ? ' #' + (test.repeatEachIndex + 1) : ''}`,
@@ -116,7 +117,8 @@ class ReportingLabsReporter {
116
117
  project,
117
118
  tags: test.tags,
118
119
  annotations,
119
- meta: this.extractMeta(test),
120
+ meta: extracted.meta,
121
+ links: Object.keys(extracted.links).length ? extracted.links : undefined,
120
122
  outcome,
121
123
  expectedFailure,
122
124
  note,
@@ -195,7 +197,7 @@ class ReportingLabsReporter {
195
197
  ...(this.options.dimensionOrder ?? {}),
196
198
  },
197
199
  project: this.options.project,
198
- links: this.options.links ?? {},
200
+ links: this.linkUrls(),
199
201
  customCss: this.options.customCss ?? '',
200
202
  editorLinks: this.options.editorLinks ?? !process.env.CI,
201
203
  },
@@ -352,10 +354,34 @@ class ReportingLabsReporter {
352
354
  metaKeys() {
353
355
  return [...new Set([...this.dimensions(), ...META_KEYS, ...Object.keys(this.options.links ?? {}).map(k => k.toLowerCase())])].filter(k => k !== '*');
354
356
  }
355
- /** Pull meta values (priority, owner, story, epic...) from annotations and tags. */
357
+ /** The link template for a (lower-cased) meta key, falling back to '*'. */
358
+ linkTemplate(key) {
359
+ let star;
360
+ for (const [k, v] of Object.entries(this.options.links ?? {})) {
361
+ if (k.toLowerCase() === key)
362
+ return v;
363
+ if (k === '*')
364
+ star = v;
365
+ }
366
+ return star;
367
+ }
368
+ /** `links` reduced to URL templates, the shape the template expects. */
369
+ linkUrls() {
370
+ const out = {};
371
+ // Meta keys are lower-cased on the way in, so link keys must be too (links: { testCaseId } did not match before).
372
+ for (const [k, v] of Object.entries(this.options.links ?? {}))
373
+ out[k.toLowerCase()] = typeof v === 'string' ? v : v.url;
374
+ return out;
375
+ }
376
+ /**
377
+ * Pull meta values (priority, owner, story, epic...) from annotations and tags.
378
+ * An object value (meta({ octaneTestCase: { id, p } })) is folded to its display text and, when the key has a
379
+ * link template, to a ready-made href in `links`; the other fields never show in the report.
380
+ */
356
381
  extractMeta(test) {
357
382
  const dims = this.metaKeys();
358
383
  const meta = {};
384
+ const links = {};
359
385
  // Tags first (describe-level, then test-level), annotations last so a test can override its describe's tags.
360
386
  for (const raw of test.tags) {
361
387
  const tag = raw.replace(/^@/, '');
@@ -371,10 +397,22 @@ class ReportingLabsReporter {
371
397
  }
372
398
  for (const a of test.annotations) {
373
399
  const k = a.type.toLowerCase();
374
- if (dims.includes(k) && a.description)
400
+ if (!dims.includes(k) || !a.description)
401
+ continue;
402
+ const fields = parseObject(a.description);
403
+ if (!fields) {
375
404
  meta[k] = a.description;
405
+ continue;
406
+ }
407
+ const tpl = this.linkTemplate(k);
408
+ const display = typeof tpl === 'object' && tpl.display ? tpl.display : '{id}';
409
+ const shown = fill(display, fields, false);
410
+ meta[k] = shown || String(fields.id ?? Object.values(fields)[0] ?? '');
411
+ const url = typeof tpl === 'string' ? tpl : tpl?.url;
412
+ if (url)
413
+ links[k] = fill(url, fields, true);
376
414
  }
377
- return meta;
415
+ return { meta, links };
378
416
  }
379
417
  /** A local image file (path relative to the config) is embedded as a data URI so the report stays self-contained. URLs and data URIs pass through. */
380
418
  resolveLogo(logo) {
@@ -622,3 +660,25 @@ function stripAnsi(s) {
622
660
  function sanitize(s) {
623
661
  return s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 40);
624
662
  }
663
+ /** A JSON object literal in an annotation description (what meta() writes for object values), else undefined. */
664
+ function parseObject(text) {
665
+ if (!text.startsWith('{'))
666
+ return undefined;
667
+ try {
668
+ const v = JSON.parse(text);
669
+ return v && typeof v === 'object' && !Array.isArray(v) ? v : undefined;
670
+ }
671
+ catch {
672
+ return undefined;
673
+ }
674
+ }
675
+ /** Replace {field} placeholders; for URLs each value is encoded except '/' and ':' so a value like '4001/14014' survives. */
676
+ function fill(template, fields, forUrl) {
677
+ return template.replace(/\{(\w+)\}/g, (_m, name) => {
678
+ const v = fields[name];
679
+ if (v === undefined || v === null)
680
+ return '';
681
+ const text = String(v);
682
+ return forUrl ? encodeURIComponent(text).replace(/%2F/gi, '/').replace(/%3A/gi, ':') : text;
683
+ });
684
+ }