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 +179 -22
- package/bin/build-template.js +101 -0
- package/dist/auto.js +52 -15
- package/dist/meta.d.ts +3 -0
- package/dist/meta.js +7 -1
- package/dist/reporter.d.ts +9 -1
- package/dist/reporter.js +65 -5
- package/dist/template.html +1659 -0
- package/dist/template.js +219 -4
- package/dist/types.d.ts +22 -7
- package/package.json +2 -2
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 |
|
|
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?
|
|
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
|
-
**
|
|
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
|
-
|
|
360
|
-
|
|
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
|
-
|
|
416
|
+
mv my-report all-shards/s1
|
|
364
417
|
```
|
|
365
418
|
|
|
366
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
406
|
-
|
|
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:
|
|
498
|
+
with:
|
|
499
|
+
name: merged-report
|
|
500
|
+
path: merged/
|
|
501
|
+
retention-days: 30
|
|
409
502
|
```
|
|
410
503
|
|
|
411
|
-
**
|
|
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
|
|
414
|
-
-
|
|
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()
|
|
190
|
+
api = pw();
|
|
161
191
|
}
|
|
162
192
|
catch {
|
|
163
193
|
return;
|
|
164
194
|
}
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
|
|
179
|
-
|
|
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
|
-
|
|
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
|
/**
|
package/dist/reporter.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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:
|
|
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.
|
|
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
|
-
/**
|
|
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)
|
|
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
|
+
}
|