reporting-labs 0.1.5 → 0.2.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
@@ -2,15 +2,15 @@
2
2
 
3
3
  # reportingLabs
4
4
 
5
- **One HTML file that tells you what broke, who owns it, and whether it is new.**
5
+ **A beautiful test report in one HTML file. It tells you what broke, who owns it, and whether it is new.**
6
6
 
7
- reportingLabs turns a test run into a single, self-contained HTML report. No server, no upload, no dashboard to log into. Open the file, or attach it to a CI job, an email or a Slack message.
7
+ reportingLabs turns a test run into a single HTML file. No server. No upload. No login. Open the file in a browser, attach it to a CI job, or send it on Slack or email. It just works.
8
8
 
9
- It is runner-agnostic by design: the report is built from a plain JSON model that adapters feed. The **Playwright adapter ships today**; WebdriverIO, Cypress and Jest/Vitest adapters are on the roadmap.
9
+ Today it ships with a **Playwright** reporter. WebdriverIO, Cypress and Jest/Vitest are on the roadmap.
10
10
 
11
11
  <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
12
 
13
- ## Quick start
13
+ ## Quick start (2 minutes)
14
14
 
15
15
  **1. Install**
16
16
 
@@ -18,82 +18,121 @@ It is runner-agnostic by design: the report is built from a plain JSON model tha
18
18
  npm i -D reporting-labs
19
19
  ```
20
20
 
21
- **2. Add the reporter to `playwright.config.ts`**
21
+ **2. Create the config file**
22
+
23
+ ```bash
24
+ npx reporting-labs init
25
+ ```
26
+
27
+ This creates `reporting-labs.config.ts` next to your `playwright.config.ts`. Every option is in there with a short comment. Most lines are commented out. Uncomment what you want, delete what you do not need.
28
+
29
+ **3. Add the reporter to `playwright.config.ts`**
22
30
 
23
31
  ```ts
32
+ import { defineConfig } from '@playwright/test';
33
+ import reportingLabs from './reporting-labs.config';
34
+
24
35
  export default defineConfig({
25
- reporter: [['reporting-labs', { title: 'My app – regression' }]],
36
+ reporter: [
37
+ ['list'],
38
+ ['reporting-labs', reportingLabs], // <- add this line
39
+ ],
26
40
  });
27
41
  ```
28
42
 
29
- **3. Run your tests and open the report**
43
+ Your other reporters (list, html, blob...) keep working as before.
44
+
45
+ **4. Run tests and open the report**
30
46
 
31
47
  ```bash
32
48
  npx playwright test
33
49
  open reporting-labs/index.html
34
50
  ```
35
51
 
36
- That is all. Everything below is optional.
52
+ Done. Everything below is optional.
53
+
54
+ > **In a hurry?** Skip the config file and pass options inline: `reporter: [['reporting-labs', { title: 'My app' }]]`.
55
+
56
+ ## Add details to your tests (3 small helpers)
57
+
58
+ The report already shows steps, screenshots, videos, traces and errors on its own. Three small helpers add the rest. Import them from `reporting-labs` and call them inside a test.
59
+
60
+ ### `meta()`: who owns this test and how important it is
61
+
62
+ ```ts
63
+ import { meta } from 'reporting-labs';
37
64
 
38
- ## Keep the reporter options in their own file (recommended)
65
+ test('completes purchase', async ({ page }) => {
66
+ meta({ priority: 'P0', severity: 'blocker', owner: 'naveen', feature: 'payment', epic: 'EPIC-18', story: 'SHOP-250' });
67
+ // ... your test as usual
68
+ });
69
+ ```
39
70
 
40
- As soon as you set more than a title, put the reportingLabs options in a separate file. Your `playwright.config.ts` stays short, and all report settings live in one place.
71
+ One line per test. With this the report can rank failures by priority, group them by owner or feature, and link to your Jira stories.
41
72
 
42
- **Step 1.** Create the file (or let the CLI do it with `npx reporting-labs init`):
73
+ - Known keys: `priority`, `severity`, `owner`, `feature`, `epic`, `story`, `issue`, `component`, `team`. Any other key you pass is shown too.
74
+ - Your Playwright tags like `@sanity` or `@regression` stay as they are and still show on the test.
75
+ - To make story and epic keys clickable, set `links` in the config: `links: { story: 'https://yourteam.atlassian.net/browse/{id}' }`.
76
+
77
+ ### `log()`: a line in the report
43
78
 
44
79
  ```ts
45
- // reporting-labs.config.ts (next to playwright.config.ts)
46
- import type { ReportingLabsOptions } from 'reporting-labs';
47
-
48
- const config: ReportingLabsOptions = {
49
- title: 'My app – regression',
50
- outputFolder: 'reports/reporting-labs',
51
- embedVideos: true,
52
- metadata: { env: process.env.TEST_ENV ?? 'local' },
53
- // links: { story: 'https://yourteam.atlassian.net/browse/{id}' },
54
- };
55
-
56
- export default config;
80
+ import { log } from 'reporting-labs';
81
+
82
+ await log('cart is empty, adding 2 items');
83
+ await log('order id', orderId); // extra values are appended
57
84
  ```
58
85
 
59
- **Step 2.** Import it in `playwright.config.ts` and add one line to your reporter list. Your other reporters keep working as before.
86
+ Each line gets a timestamp. Lines with "error" or "fail" show in red, "warn" in amber.
87
+
88
+ ### `testData()`: show the data the test used
60
89
 
61
90
  ```ts
62
- import { defineConfig } from '@playwright/test';
63
- import reportingLabs from './reporting-labs.config';
91
+ import { testData } from 'reporting-labs';
64
92
 
65
- export default defineConfig({
66
- reporter: [
67
- ['list'],
68
- ['html', { open: 'never' }],
69
- ['reporting-labs', reportingLabs], // <- this line
70
- ],
93
+ await testData({ user: 'naveen@x.com', password: 'S3cret' }, 'Login'); // object → key/value block
94
+ await testData(rowsFromExcelOrJson, 'Coupons'); // array of objects → table
95
+ await testData(fs.readFileSync('data/users.csv', 'utf8'), 'users.csv'); // CSV text → table
96
+ ```
97
+
98
+ **Secrets are masked automatically.** Passwords, tokens, API keys, `Authorization` and `Cookie` headers, JWTs and `Bearer ...` values show as `****`. Add your own keys with `maskKeys: ['otp', 'pan']` in the config.
99
+
100
+ ## API calls: recorded on their own
101
+
102
+ Nothing to add to your tests. The config file created by `init` starts with `import 'reporting-labs/auto'`, and that is the switch. From then on every call made with Playwright's `request` fixture or `page.request` is recorded as it happens.
103
+
104
+ ```ts
105
+ test('creates an order', async ({ request }) => {
106
+ const res = await request.post('/v1/orders', { headers, data }); // recorded, nothing else to do
107
+ expect(res.status()).toBe(201);
71
108
  });
72
109
  ```
73
110
 
74
- That is it. The `ReportingLabsOptions` type gives you autocomplete for every option in your editor.
111
+ Each call shows in the test detail and in the **API** tab with method, URL, status, time, headers, request body and response body. A **Copy as cURL** button lets you replay it from a terminal. Failed calls (4xx, 5xx, no connection) are highlighted.
112
+
113
+ If you do not use the config file, add `import 'reporting-labs/auto'` at the top of `playwright.config.ts` instead.
75
114
 
76
- If your reporter list differs between CI and local (a common pattern), add the same line to both branches:
115
+ Only calls that do not go through Playwright (Node `fetch`, axios, a Java service) need a manual record:
77
116
 
78
117
  ```ts
79
- reporter: process.env.CI
80
- ? [['blob'], ['html', { open: 'never' }], ['reporting-labs', reportingLabs]]
81
- : [['list'], ['html', { open: 'never' }], ['reporting-labs', reportingLabs]],
118
+ import { api } from 'reporting-labs';
119
+
120
+ await api({ method: 'GET', url, status: res.status, duration, responseBody: await res.json() });
82
121
  ```
83
122
 
84
123
  ## Examples you can copy
85
124
 
86
- The [`examples/`](https://github.com/naveenautomationlabs/reporting-labs/tree/main/examples) folder has one small spec per feature. Each one runs offline in a few seconds, so you can read it, run it, and copy what you need.
125
+ The [`examples/`](https://github.com/naveenautomationlabs/reporting-labs/tree/main/examples) folder has one small spec per feature. Read it, run it, copy what you need.
87
126
 
88
127
  | Spec | What it shows |
89
128
  |---|---|
90
- | [01-tag-your-tests.spec.ts](https://github.com/naveenautomationlabs/reporting-labs/blob/main/examples/01-tag-your-tests.spec.ts) | `meta()` with priority, severity, owner, feature, story; the same via tags and annotations |
129
+ | [01-tag-your-tests.spec.ts](https://github.com/naveenautomationlabs/reporting-labs/blob/main/examples/01-tag-your-tests.spec.ts) | `meta()` with priority, severity, owner, feature, epic, story |
91
130
  | [02-logs-and-test-data.spec.ts](https://github.com/naveenautomationlabs/reporting-labs/blob/main/examples/02-logs-and-test-data.spec.ts) | `log()` lines and `testData()` as key/value, table and CSV, with secrets masked |
92
- | [03-api-calls.spec.ts](https://github.com/naveenautomationlabs/reporting-labs/blob/main/examples/03-api-calls.spec.ts) | `recordApi()` around `request.post`, `api()` for manual records, a 404 in the API tab |
93
- | [04-steps-and-attachments.spec.ts](https://github.com/naveenautomationlabs/reporting-labs/blob/main/examples/04-steps-and-attachments.spec.ts) | `test.step()` bars, screenshot / JSON attachments, visual comparison viewer |
131
+ | [03-api-calls.spec.ts](https://github.com/naveenautomationlabs/reporting-labs/blob/main/examples/03-api-calls.spec.ts) | Plain `request.post` / `patch` / `delete` and `page.request` calls to the public [gorest.in](https://gorest.in/) API, recorded on their own; a 403; `api()` for other clients |
132
+ | [04-steps-and-attachments.spec.ts](https://github.com/naveenautomationlabs/reporting-labs/blob/main/examples/04-steps-and-attachments.spec.ts) | `test.step()` bars, screenshot and JSON attachments, visual comparison viewer |
94
133
  | [05-outcomes.spec.ts](https://github.com/naveenautomationlabs/reporting-labs/blob/main/examples/05-outcomes.spec.ts) | skip with a reason, fixme, expected failure, timeout, a plain failure, a flaky test |
95
- | [06-bdd-style.spec.ts](https://github.com/naveenautomationlabs/reporting-labs/blob/main/examples/06-bdd-style.spec.ts) | Given / When / Then steps rendered as Gherkin |
96
- | [reporting-labs.config.ts](https://github.com/naveenautomationlabs/reporting-labs/blob/main/examples/reporting-labs.config.ts) | A complete reporter config, every option commented |
134
+ | [06-bdd-style.spec.ts](https://github.com/naveenautomationlabs/reporting-labs/blob/main/examples/06-bdd-style.spec.ts) | Given / When / Then steps shown as Gherkin |
135
+ | [reporting-labs.config.ts](https://github.com/naveenautomationlabs/reporting-labs/blob/main/examples/reporting-labs.config.ts) | A complete config with comments |
97
136
  | [playwright.config.ts](https://github.com/naveenautomationlabs/reporting-labs/blob/main/examples/playwright.config.ts) | How the reporter sits next to the built-in reporters |
98
137
 
99
138
  ```bash
@@ -102,16 +141,18 @@ cd reporting-labs/examples && npm install && npx playwright test
102
141
  open reporting-labs/index.html
103
142
  ```
104
143
 
144
+ Run it two or three times to see the history features (new vs known failures, flaky dots, got slower, trend).
145
+
105
146
  ## A tour of the report
106
147
 
107
- ### Overview: the state of the run in one screen
148
+ ### Overview: the whole run on one screen
108
149
 
109
- - **Tiles**: pass rate with a ring and the change from the previous run, then passed / failed / flaky / skipped. Click a tile to see those tests.
110
- - **Run strip**: every test as one cell, in run order. Hover for the name, click to open.
111
- - **Needs attention**: failures ranked by priority and severity, with the spec file, the owner, and whether the failure is new or has been failing since a given build.
112
- - **Failure clusters**: failures grouped by error message, so 30 red tests with one root cause read as one problem.
113
- - **Breakdown**: stacked bars per priority, severity, feature, owner, spec file, project or tag. Click a row to filter the test list. With more than one project you also get a feature × project heatmap.
114
- - **Slowest tests** and **Got slower** (tests that took 2× longer than last run), **Flakiest tests**, **Skipped** (with reasons), **Environment** and the **Trend** across runs.
150
+ - **Tiles**: pass rate with a ring and the change from the last run, then passed / failed / flaky / skipped. Click a tile to see those tests.
151
+ - **Run strip**: every test as one small cell, in run order. Hover for the name, click to open.
152
+ - **Needs attention**: failures sorted by priority and severity, with the spec file, the owner, and whether the failure is new or has been failing for a while.
153
+ - **Failure clusters**: failures grouped by error message. 30 red tests with one cause read as one problem.
154
+ - **Breakdown**: bars per priority, severity, feature, owner, spec file, project or tag. Click a row to filter the test list. With more than one project you also get a feature × project heatmap.
155
+ - **Slowest tests**, **Got slower** (2× slower than last run), **Flakiest tests**, **Skipped** (with reasons), **Environment** and the **Trend** across runs.
115
156
 
116
157
  <p align="center"><img src="https://raw.githubusercontent.com/naveenautomationlabs/reporting-labs/main/docs/heatmap.png" alt="Breakdown card with the feature by project heatmap" width="900"></p>
117
158
 
@@ -120,19 +161,18 @@ open reporting-labs/index.html
120
161
  ### Failures: everything you need to triage
121
162
 
122
163
  - **By owner**: who to ping, with failed and flaky counts. Click an owner to filter.
123
- - **Download CSV / JSON**: the failed and flaky tests with title, spec, project, priority, owner, ticket, attempts, duration, first error line and new/known status. Ready for Jira or a sheet.
124
- - **Copy summary**: a Slack/Teams-ready message with top failures, owners, ticket keys and an owner breakdown.
125
- - The table shows every failed or flaky test with its history over the last runs as dots.
164
+ - **Download CSV / JSON**: all failed and flaky tests with title, spec, project, priority, owner, ticket, attempts, duration, first error line and new/known status. Ready for Jira or a sheet.
165
+ - **Copy summary**: a Slack or Teams message with the top failures, owners and ticket keys.
166
+ - The table shows every failed or flaky test with its last runs as dots.
126
167
 
127
168
  <p align="center"><img src="https://raw.githubusercontent.com/naveenautomationlabs/reporting-labs/main/docs/failures.png" alt="Failures page" width="900"></p>
128
169
 
129
- ### Test detail: the error, the steps, the evidence
170
+ ### Test detail: the error, the steps, the proof
130
171
 
131
- - **Expected vs received** side by side with the difference highlighted. Object diffs are colored line by line.
132
- - Error **location** (linked to VS Code), Playwright's **code snippet**, full message and stack.
172
+ - **Expected vs received** side by side with the difference highlighted.
173
+ - Error **location** (opens in VS Code), Playwright's **code snippet**, full message and stack.
133
174
  - **Steps** with a bar per step showing its share of the test time. Given/When/Then titles are styled as Gherkin.
134
- - Retries as tabs, screenshots inline (click to zoom), videos, traces, console output, logs, test data and API calls.
135
- - **Open in VS Code** jumps to the failing line.
175
+ - Retries as tabs. Screenshots inline (click to zoom), videos, traces, console output, logs, test data and API calls.
136
176
 
137
177
  <p align="center"><img src="https://raw.githubusercontent.com/naveenautomationlabs/reporting-labs/main/docs/test-detail.png" alt="Test detail with expected vs received diff and step bars" width="900"></p>
138
178
 
@@ -140,143 +180,97 @@ open reporting-labs/index.html
140
180
 
141
181
  <p align="center"><img src="https://raw.githubusercontent.com/naveenautomationlabs/reporting-labs/main/docs/timeline.png" alt="Timeline by worker" width="900"></p>
142
182
 
143
- ## Make the report smarter: tag your tests
144
-
145
- One line per test gives you priority ranking, owner rollups, feature breakdowns and Jira links.
146
-
147
- ```ts
148
- import { meta } from 'reporting-labs';
149
-
150
- test('completes purchase', async ({ page }) => {
151
- meta({ priority: 'P0', severity: 'blocker', owner: 'naveen', feature: 'payment', story: 'SHOP-250' });
152
- // ...
153
- });
154
- ```
155
-
156
- Full example: [examples/01-tag-your-tests.spec.ts](https://github.com/naveenautomationlabs/reporting-labs/blob/main/examples/01-tag-your-tests.spec.ts). Tags work too: `{ tag: ['@P1', '@severity:critical', '@owner:priya'] }`. Plain annotations (`test.info().annotations.push({ type: 'priority', description: 'P1' })`) are picked up as well.
157
-
158
- Turn story, epic or issue keys into links:
159
-
160
- ```ts
161
- links: { story: 'https://acme.atlassian.net/browse/{id}', epic: 'https://acme.atlassian.net/browse/{id}' }
162
- ```
163
-
164
- ## Logs, test data and API calls
165
-
166
- ```ts
167
- import { log, testData, api, recordApi } from 'reporting-labs';
168
-
169
- test('creates an order', async ({ request }) => {
170
- await log('starting with an empty cart'); // timestamped log line
171
-
172
- await testData({ user: 'naveen@x.com', password: 'S3cret' }, 'Login'); // object → key/value block
173
- await testData(rowsFromExcelOrJson, 'Coupons'); // array of objects → table
174
- await testData(fs.readFileSync('data/users.csv', 'utf8'), 'users.csv'); // CSV string → table
175
-
176
- const res = await recordApi('POST', '/v1/orders', { headers, data },
177
- () => request.post('/v1/orders', { headers, data })); // request + response panel
178
- });
179
- ```
180
-
181
- Full examples: [02-logs-and-test-data.spec.ts](https://github.com/naveenautomationlabs/reporting-labs/blob/main/examples/02-logs-and-test-data.spec.ts) and [03-api-calls.spec.ts](https://github.com/naveenautomationlabs/reporting-labs/blob/main/examples/03-api-calls.spec.ts). Passwords, tokens, API keys, `Authorization` / `Cookie` headers, JWTs and `Bearer …` values are masked as `****` everywhere. Add your own keys with `maskKeys: ['otp', 'pan']`.
182
-
183
183
  ## Run history: new vs known, flaky, slower
184
184
 
185
- The reporter keeps `reporting-labs.history.json` next to your config (last 30 runs by default). Commit it, or cache it in CI, and the report starts answering the questions you ask first:
185
+ The reporter keeps a small file, `reporting-labs.history.json`, next to your config. It remembers the last 30 runs. Commit it, or cache it in CI, and the report starts answering the questions you ask first:
186
186
 
187
187
  | Question | Where it shows |
188
188
  |---|---|
189
- | Did this break just now, or has it been red for days? | `new this run` / `failing since #1838` on every failure; the Failed tile splits the count |
189
+ | Did this break just now, or has it been red for days? | `new this run` or `failing since #1838` on every failure |
190
190
  | Which tests are flaky? | Last-10-runs dots on every failure, plus the Flakiest tests card |
191
- | What got slower? | Got slower tab on the Slowest card (2× slower than last run) |
192
- | Are we trending up or down? | Trend chart with pass rate, fail rate and duration per run, hover for details |
191
+ | What got slower? | Got slower tab on the Slowest card |
192
+ | Are we getting better or worse? | Trend chart with pass rate, fail rate and duration per run |
193
193
 
194
- The first run has nothing to compare with; these cards fill in from the second run.
194
+ The first run has nothing to compare with. These cards fill in from the second run.
195
195
 
196
196
  ## Screenshots, videos, traces
197
197
 
198
- Nothing extra to do. Use your runner's own settings and the report picks the attachments up:
198
+ Nothing extra to do. Use Playwright's own settings and the report picks them up:
199
199
 
200
200
  ```ts
201
201
  use: {
202
202
  screenshot: 'only-on-failure', // shown inline, click to zoom
203
- video: 'retain-on-failure', // inline player (copied to ./assets, or embedded with embedVideos: true)
203
+ video: 'retain-on-failure', // inline player
204
204
  trace: 'on-first-retry', // trace card with download and how to open it
205
205
  }
206
206
  ```
207
207
 
208
- `toHaveScreenshot` failures get a visual comparison viewer: slider, side by side, and diff. Anything you attach with `test.info().attach()` shows up too: images inline, text/JSON as a code block, everything else as a download.
208
+ `toHaveScreenshot` failures get a visual comparison viewer: slider, side by side, and diff. Anything you attach with `test.info().attach()` shows up too: images inline, JSON and CSV as a table or key/value block, text as a code block, everything else as a download.
209
209
 
210
210
  ## What the report covers
211
211
 
212
- Every outcome Playwright can produce is shown, not just pass/fail:
212
+ Every outcome Playwright can produce, not just pass and fail:
213
213
 
214
- - passed, failed, flaky (passed on retry), skipped with the `test.skip` / `test.fixme` reason, timed out with the exceeded timeout, interrupted
215
- - `test.fail()` tests that fail as expected count as passed with an "Expected failure" badge; one that unexpectedly passes is reported as failed with a note
216
- - errors outside tests (a spec that throws at load, global setup, a worker crash) get their own card at the top of the overview
217
- - interrupted runs and global timeouts show a banner with how many tests did not finish
218
- - shard and worker count in the Environment card, together with Playwright and Node versions, OS, browsers, the CI job link (GitHub Actions, GitLab, Jenkins, CircleCI, Azure, Bitbucket) and the git commit
214
+ - passed, failed, flaky (passed on retry), skipped with the `test.skip` / `test.fixme` reason, timed out, interrupted
215
+ - `test.fail()` tests that fail as expected count as passed with an "Expected failure" badge
216
+ - errors outside tests (a spec that throws at load, global setup, a worker crash) get their own card at the top
217
+ - interrupted runs show a banner with how many tests did not finish
218
+ - the Environment card shows Playwright and Node versions, OS, browsers, workers, shard, the CI job link and the git commit
219
219
 
220
- ## Options
220
+ ## All options
221
221
 
222
- All options are optional. Pass them as the second element of the reporter tuple.
222
+ Every option is optional. `npx reporting-labs init` writes them all, with comments, into `reporting-labs.config.ts` (`--js` for JavaScript, `--force` to overwrite).
223
223
 
224
224
  | Option | Default | What it does |
225
225
  |---|---|---|
226
- | `title` | `'Test report'` | Report title in the header |
227
- | `logo` | – | Path or data URI of your logo, shown next to the title |
226
+ | `title` | `'Test report'` | Title in the header |
227
+ | `logo` | – | Path, URL or data URI of your logo |
228
228
  | `project` | – | `{ name, version, team, url }` shown under the title |
229
- | `metadata` | `{}` | Key/value chips in the header, e.g. `{ env: 'staging', build: '#1842' }`. `build` labels the run in history; in CI the run number is used when it is not set |
230
- | `env` | – | Extra rows for the Environment card, e.g. `{ 'App build': '2.4.0-rc3' }` |
229
+ | `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 |
230
+ | `env` | – | Extra rows on the Environment card |
231
+ | `links` | `{}` | Turn meta keys into links. `{id}` is replaced by the value |
232
+ | `maskKeys` | `[]` | Extra keys to mask as `****` |
231
233
  | `dimensions` | `['priority','severity','feature','owner']` | Meta keys that get charts and filters |
232
- | `dimensionOrder` | P0…P4, blocker…trivial | Sort order per dimension, e.g. `{ severity: ['blocker','critical','major','minor'] }` |
233
- | `links` | `{}` | URL templates per meta key, `{id}` is replaced by the value |
234
- | `maskKeys` | `[]` | Extra keys to mask in logs, data and API panels |
235
- | `widgets` | all on | Hide cards: `{ tags: false, timeline: false, flaky: false, environment: false, skipped: false, ... }` |
236
- | `sections` | `[]` | Extra HTML sections below the summary, e.g. release notes |
234
+ | `dimensionOrder` | P0…P4, blocker…trivial | Sort order per dimension |
235
+ | `widgets` | all on | Hide cards: `{ tags: false, timeline: false, ... }` |
236
+ | `sections` | `[]` | Extra HTML below the summary, e.g. release notes |
237
237
  | `history` | `{ enabled: true, keep: 30 }` | Run history file; `file` sets a custom path |
238
- | `palette` | `'lab'` | `'lab'` (blue), `'ocean'`, `'ember'`, `'mono'`. Viewers can switch in the header |
239
- | `accent` | palette accent | Override the accent with your brand color |
238
+ | `palette` | `'lab'` | `'lab'` (blue), `'ocean'`, `'ember'`, `'mono'` |
239
+ | `accent` | palette accent | Your brand color |
240
240
  | `theme` | `'auto'` | `'light'`, `'dark'` or follow the OS |
241
241
  | `customCss` | `''` | CSS appended to the report |
242
- | `editorLinks` | `true` locally, `false` in CI | "Open in VS Code" links |
242
+ | `editorLinks` | on locally, off in CI | "Open in VS Code" links |
243
243
  | `bdd` | auto | Style Given/When/Then steps as Gherkin |
244
- | `outputFolder` | `'reporting-labs'` | Where the report and copied assets go |
244
+ | `outputFolder` | `'reporting-labs'` | Where the report goes |
245
245
  | `outputFile` | `'index.html'` | Report file name |
246
- | `embedAttachments` | `true` | Inline screenshots as base64 (single file) |
247
- | `embedLimit` | 2 MB | Larger attachments are copied to `./assets` instead |
248
- | `embedVideos` | `false` | Inline videos too (big file) |
249
- | `embedFonts` | `true` | Bundle IBM Plex (~140 KB) so the report looks the same offline |
246
+ | `embedAttachments` | `true` | Screenshots inside the HTML (one file) |
247
+ | `embedLimit` | 2 MB | Bigger attachments are copied to `./assets` |
248
+ | `embedVideos` | `false` | Videos inside the HTML too (bigger file, no folder issues) |
249
+ | `embedFonts` | `true` | Bundle the fonts (~140 KB) so it looks the same offline |
250
250
  | `announce` | `true` | Print the report path after the run |
251
251
 
252
- A fuller example:
252
+ If your reporter list differs between CI and local, add the same line to both:
253
253
 
254
254
  ```ts
255
- reporter: [['reporting-labs', {
256
- title: 'ShopLite – nightly regression',
257
- project: { name: 'ShopLite Web', version: '2.4.0', team: 'QA Platform' },
258
- metadata: { env: 'staging', branch: process.env.GIT_BRANCH ?? 'main', build: process.env.BUILD_ID ?? 'local' },
259
- links: { story: 'https://acme.atlassian.net/browse/{id}' },
260
- sections: [{ title: 'Release notes', html: '<p>Checkout v2 at 50% rollout.</p>' }],
261
- }]]
255
+ reporter: process.env.CI
256
+ ? [['blob'], ['reporting-labs', reportingLabs]]
257
+ : [['list'], ['reporting-labs', reportingLabs]],
262
258
  ```
263
259
 
264
- `npx reporting-labs init` writes a starter `reporting-labs.config.ts`; see [Keep the reporter options in their own file](#keep-the-reporter-options-in-their-own-file-recommended).
265
-
266
260
  ## Running in CI
267
261
 
268
- The report is a plain file written next to your tests, so it works anywhere `npx playwright test` runs: locally, GitHub Actions, GitLab, Jenkins, CircleCI, Azure Pipelines, Bitbucket. Nothing phones home and no fonts are fetched, so it also works in locked-down networks.
262
+ The report is a plain file written next to your tests. It works anywhere `npx playwright test` runs: GitHub Actions, GitLab, Jenkins, CircleCI, Azure Pipelines, Bitbucket, your laptop. Nothing is sent anywhere and no fonts are fetched, so it also works on locked-down networks.
269
263
 
270
- What happens automatically in CI:
264
+ What happens on its own in CI:
271
265
 
272
- - The Environment card links the CI job and the commit (GitHub Actions, GitLab, Jenkins, CircleCI, Azure, Bitbucket are detected from their environment variables).
273
- - The run is labelled with the CI run number in the history and the trend chart, unless you set `metadata.build` yourself.
274
- - "Open in VS Code" links are off when the `CI` variable is set, because they would point at the runner's paths. Set `editorLinks: true` to force them.
266
+ - The Environment card links the CI job and the commit.
267
+ - The run is labelled with the CI run number in the history and the trend, unless you set `metadata.build`.
268
+ - "Open in VS Code" links are off, because they would point at the runner's paths.
275
269
 
276
270
  Two things to set up:
277
271
 
278
- 1. **Publish the report.** Upload `reporting-labs/` as a build artifact (or archive it in Jenkins). Screenshots and fonts are inside `index.html`; videos and large files sit in `reporting-labs/assets/`.
279
- 2. **Keep the history.** `reporting-labs.history.json` is what powers the trend, new vs known failures, flaky history and duration regressions. On GitHub Actions restore and save it with `actions/cache`; on Jenkins the workspace usually persists on its own.
272
+ 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.
273
+ 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.
280
274
 
281
275
  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).
282
276
 
@@ -294,23 +288,22 @@ Ready-to-copy samples: [docs/ci/github-actions.yml](https://github.com/naveenaut
294
288
  path: reporting-labs/
295
289
  ```
296
290
 
297
- **Jenkins HTML Publisher note.** Jenkins' default Content-Security-Policy blocks inline JavaScript, so a single-file report shows up blank inside Jenkins (Playwright's own HTML report has the same issue). Either download the archived artifact and open it locally, or have an admin relax the policy in the script console: `System.setProperty("hudson.model.DirectoryBrowserSupport.CSP", "")`.
291
+ **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", "")`.
298
292
 
299
293
  ## Good to know
300
294
 
301
- - **Single file.** Screenshots and fonts are embedded, so `index.html` works from a mail attachment or a CI artifact. Videos and large files go to `./assets` next to it, so keep the folder together when you move or share the report.
302
- - **Re-running tests replaces the report.** The previous report stays intact until the new run finishes, then `index.html` and `assets/` are replaced. A report tab opened from an earlier run will lose its videos at that point; archive the folder if you need to keep it.
303
- - **Videos on macOS.** If the report lives in Downloads, Desktop or Documents and you open it as a file, Chrome may be blocked from reading the sibling `assets/` files (the player shows a clear message). Allow Chrome under System Settings → Privacy & Security → Files and Folders, move the project elsewhere, or set `embedVideos: true` to put videos inside the HTML.
304
- - **Video download.** Served over http (CI artifact viewer, a local server) the download link saves the file; opened as a plain file the browser opens the video in a new tab instead, where the player's menu offers Save.
295
+ - **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.
296
+ - **Each run replaces the report.** The old report stays until the new run finishes. Archive the folder if you want to keep an old one.
297
+ - **Videos on macOS.** If the report is in Downloads, Desktop or Documents and you open it as a file, Chrome may not be allowed to read the `assets/` folder (the player shows a clear message). Allow Chrome under System Settings → Privacy & Security → Files and Folders, move the project elsewhere, or set `embedVideos: true`.
305
298
  - **Keyboard.** `j` / `k` next and previous test, `f` failed only, `/` search, `1`–`5` switch views, `Esc` close.
306
- - **Print.** A print stylesheet is included for PDF export.
307
- - **Themes.** Light and dark follow the OS; the toggle in the header remembers the choice.
299
+ - **Print.** A print stylesheet is included, so "Save as PDF" works.
300
+ - **Themes.** Light and dark follow the OS. The toggle in the header remembers your choice.
308
301
 
309
302
  ## Roadmap
310
303
 
311
- - WebdriverIO, Cypress, Jest/Vitest, JUnit XML adapters via the shared JSON schema
312
- - AI summary and failure clustering (bring your own API key)
313
- - Hosted history dashboard across branches/projects
304
+ - WebdriverIO, Cypress, Jest/Vitest and JUnit XML adapters
305
+ - AI summary of failures (bring your own API key)
306
+ - Hosted history dashboard across branches and projects
314
307
 
315
308
  ## License
316
309
 
package/bin/cli.js CHANGED
@@ -1,32 +1,45 @@
1
1
  #!/usr/bin/env node
2
2
  const fs = require('fs');
3
3
  const path = require('path');
4
- const [cmd] = process.argv.slice(2);
4
+ const args = process.argv.slice(2);
5
+ const cmd = args[0];
6
+ const has = f => args.includes(f);
5
7
 
6
8
  if (cmd === 'init') {
7
- const file = path.resolve('reporting-labs.config.ts');
8
- if (fs.existsSync(file)) { console.log('reporting-labs.config.ts already exists.'); process.exit(0); }
9
- fs.writeFileSync(file, `import type { ReportingLabsOptions } from 'reporting-labs';
10
-
11
- const config: ReportingLabsOptions = {
12
- title: 'My app – regression',
13
- // logo: 'https://example.com/logo.svg',
14
- palette: 'lab', // 'lab' (blue) | 'ocean' | 'ember' | 'mono'
15
- theme: 'auto',
16
- metadata: { env: process.env.TEST_ENV ?? 'local', branch: process.env.GIT_BRANCH ?? 'main' },
17
- links: { story: 'https://acme.atlassian.net/browse/{id}' },
18
- sections: [],
19
- };
20
- export default config;
21
- `);
22
- console.log('Created reporting-labs.config.ts');
23
- console.log("Add to playwright.config.ts: reporter: [['reporting-labs', require('./reporting-labs.config').default]]");
9
+ const js = has('--js');
10
+ const file = path.resolve(js ? 'reporting-labs.config.js' : 'reporting-labs.config.ts');
11
+ if (fs.existsSync(file) && !has('--force')) {
12
+ console.log(`${path.basename(file)} already exists. Use --force to overwrite it.`);
13
+ process.exit(1);
14
+ }
15
+ let template = fs.readFileSync(path.join(__dirname, 'config-template.txt'), 'utf8');
16
+ if (js) {
17
+ template = template
18
+ .replace("import 'reporting-labs/auto';", "require('reporting-labs/auto');")
19
+ .replace("import type { ReportingLabsOptions } from 'reporting-labs';\n", '')
20
+ .replace('const config: ReportingLabsOptions = {', "/** @type {import('reporting-labs').ReportingLabsOptions} */\nconst config = {")
21
+ .replace('export default config;', 'module.exports = config;');
22
+ }
23
+ fs.writeFileSync(file, template);
24
+ const base = path.basename(file, path.extname(file));
25
+ console.log(`Created ${path.basename(file)} with every option listed. Uncomment what you need.`);
26
+ console.log('');
27
+ console.log(`Now in playwright.config.${js ? 'js' : 'ts'}:`);
28
+ if (js) {
29
+ console.log(` const reportingLabs = require('./${base}');`);
30
+ } else {
31
+ console.log(` import reportingLabs from './${base}';`);
32
+ }
33
+ console.log(" reporter: [['list'], ['reporting-labs', reportingLabs]],");
24
34
  } else {
25
35
  console.log(`reporting-labs
26
36
 
27
- npx reporting-labs init create a starter config file
37
+ npx reporting-labs init write reporting-labs.config.ts with every option, commented
38
+ npx reporting-labs init --js same, as reporting-labs.config.js
39
+ npx reporting-labs init --force overwrite an existing config file
28
40
 
29
- Usage in playwright.config.ts:
30
- reporter: [['reporting-labs', { title: 'My report' }]]
41
+ Then in playwright.config.ts:
42
+ import reportingLabs from './reporting-labs.config';
43
+ reporter: [['list'], ['reporting-labs', reportingLabs]],
31
44
  `);
32
45
  }
@@ -0,0 +1,78 @@
1
+ import 'reporting-labs/auto'; // records every request.* / page.request call in the report (remove to switch off)
2
+ import type { ReportingLabsOptions } from 'reporting-labs';
3
+
4
+ // reportingLabs configuration.
5
+ // Every option is optional. Lines that start with // show the default; uncomment and change what you need.
6
+ // Docs: https://github.com/naveenautomationlabs/reporting-labs#options
7
+
8
+ const config: ReportingLabsOptions = {
9
+
10
+ // ── Look ─────────────────────────────────────────────────────────────────────
11
+ title: 'My app – regression', // shown in the header
12
+ // logo: 'https://example.com/logo.svg', // path, URL or data URI; shown next to the title
13
+ // palette: 'lab', // 'lab' (blue, default) | 'ocean' | 'ember' | 'mono'; viewers can switch
14
+ // accent: '#7C3AED', // your brand color instead of the palette accent
15
+ // theme: 'auto', // 'auto' (follows OS, default) | 'light' | 'dark'
16
+ // customCss: '.hdr { background: #123 }', // extra CSS appended to the report
17
+ // embedFonts: true, // bundle IBM Plex (~140 KB) so it looks the same offline
18
+
19
+ // ── Header details ───────────────────────────────────────────────────────────
20
+ // project: { name: 'ShopLite Web', version: '2.4.0', team: 'QA Platform', url: 'https://shoplite.example.com', description: '' },
21
+ metadata: { // chips in the header; `build` also labels the run in the trend
22
+ env: process.env.TEST_ENV ?? 'local',
23
+ // build: process.env.BUILD_NUMBER ?? 'local', // when missing, the CI run number is used
24
+ // branch: process.env.GIT_BRANCH ?? 'main',
25
+ },
26
+ // env: { 'App version': '2.4.0', 'Test data': 'staging-seed-12' }, // extra rows on the Environment card
27
+
28
+ // ── Output ───────────────────────────────────────────────────────────────────
29
+ // outputFolder: 'reporting-labs', // where index.html and copied attachments go
30
+ // outputFile: 'index.html',
31
+ // embedAttachments: true, // inline screenshots as base64: one file, opens anywhere
32
+ // embedLimit: 2 * 1024 * 1024, // attachments bigger than this (bytes) are copied as files
33
+ // embedVideos: false, // true = videos inside the HTML too (bigger file, no folder issues)
34
+ // announce: true, // print the report path after the run
35
+
36
+ // ── Test details ─────────────────────────────────────────────────────────────
37
+ // Values come from meta({ priority, severity, owner, feature, epic, story, ... }) in your tests.
38
+ // dimensions: ['priority', 'severity', 'feature', 'owner'], // which meta keys get charts and filters
39
+ // dimensionOrder: { severity: ['blocker', 'critical', 'major', 'minor', 'trivial'] },
40
+ // links: { // turn meta keys into links; {id} is the value
41
+ // story: 'https://acme.atlassian.net/browse/{id}',
42
+ // epic: 'https://acme.atlassian.net/browse/{id}',
43
+ // issue: 'https://acme.atlassian.net/browse/{id}',
44
+ // },
45
+ // maskKeys: ['otp', 'pan'], // extra keys to mask as **** (passwords, tokens, cookies already are)
46
+ // editorLinks: true, // "Open in VS Code" on every test; default: on locally, off in CI
47
+ // bdd: false, // style Given/When/Then steps as Gherkin; default: auto-detect
48
+
49
+ // ── Run history and trend ────────────────────────────────────────────────────
50
+ // history: {
51
+ // enabled: true,
52
+ // file: 'reporting-labs.history.json', // kept next to playwright.config; commit it or cache it in CI
53
+ // keep: 30, // runs to remember
54
+ // },
55
+
56
+ // ── Widgets on the overview (hide what you do not need) ──────────────────────
57
+ // widgets: {
58
+ // runStrip: true, // pass/fail strip under the header
59
+ // outcome: true, // outcome ring
60
+ // attention: true, // "Needs attention" list
61
+ // dimensions: true, // breakdown by priority / severity / feature / owner
62
+ // timeline: true, // timeline by worker
63
+ // durations: true, // duration spread
64
+ // tags: true, // tag cloud
65
+ // slowest: true, // slowest tests
66
+ // projects: true, // per-project results
67
+ // flaky: true, // flakiest tests over the run history
68
+ // environment: true, // Playwright, Node, OS, browsers, CI, commit
69
+ // skipped: true, // skipped tests with reasons
70
+ // },
71
+
72
+ // ── Extra content ────────────────────────────────────────────────────────────
73
+ // sections: [ // free text below the summary, HTML allowed
74
+ // { title: 'Release notes', html: '<p>Checkout v2 at 50% rollout.</p>' },
75
+ // ],
76
+ };
77
+
78
+ export default config;
package/dist/auto.d.ts ADDED
@@ -0,0 +1 @@
1
+ export {};
package/dist/auto.js ADDED
@@ -0,0 +1,181 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const PATCHED = '__reportingLabsApiCapture';
4
+ const MAX_BODY = 200 * 1024;
5
+ const TEXT_TYPES = /json|text|xml|html|javascript|x-www-form-urlencoded|graphql/i;
6
+ function pw() {
7
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
8
+ return require('@playwright/test');
9
+ }
10
+ function currentTest() {
11
+ try {
12
+ return pw().test.info();
13
+ }
14
+ catch {
15
+ return undefined;
16
+ }
17
+ }
18
+ function describeBody(v) {
19
+ if (v == null)
20
+ return undefined;
21
+ if (Buffer.isBuffer(v))
22
+ return `<binary ${v.length} bytes>`;
23
+ if (typeof v === 'string') {
24
+ try {
25
+ return JSON.parse(v);
26
+ }
27
+ catch {
28
+ return v;
29
+ }
30
+ }
31
+ return v;
32
+ }
33
+ function multipart(mp) {
34
+ const out = {};
35
+ const entries = typeof FormData !== 'undefined' && mp instanceof FormData ? Array.from(mp.entries()) : Object.entries(mp ?? {});
36
+ for (const [k, v] of entries) {
37
+ if (typeof File !== 'undefined' && v instanceof File)
38
+ out[k] = `<file ${v.name} ${v.size} bytes>`;
39
+ else if (v && typeof v === 'object' && ('buffer' in v || 'name' in v))
40
+ out[k] = `<file ${v.name ?? ''}${v.buffer ? ' ' + v.buffer.length + ' bytes' : ''}>`;
41
+ else
42
+ out[k] = v;
43
+ }
44
+ return out;
45
+ }
46
+ function requestFrom(urlOrRequest, options) {
47
+ const req = urlOrRequest && typeof urlOrRequest === 'object' && typeof urlOrRequest.url === 'function' ? urlOrRequest : undefined;
48
+ const method = String(options.method ?? (req ? req.method() : 'GET')).toUpperCase();
49
+ let url = req ? req.url() : String(urlOrRequest);
50
+ const params = options.params;
51
+ if (params instanceof URLSearchParams) {
52
+ const q = params.toString();
53
+ if (q)
54
+ url += (url.includes('?') ? '&' : '?') + q;
55
+ }
56
+ else if (params && typeof params === 'object') {
57
+ const q = Object.entries(params).map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`).join('&');
58
+ if (q)
59
+ url += (url.includes('?') ? '&' : '?') + q;
60
+ }
61
+ const headers = { ...(req ? req.headers() : {}), ...(options.headers ?? {}) };
62
+ const hasType = Object.keys(headers).some(h => h.toLowerCase() === 'content-type');
63
+ let body;
64
+ if (options.data !== undefined) {
65
+ body = describeBody(options.data);
66
+ if (!hasType && typeof options.data === 'object' && !Buffer.isBuffer(options.data))
67
+ headers['content-type'] = 'application/json';
68
+ }
69
+ else if (options.form !== undefined) {
70
+ body = options.form instanceof URLSearchParams ? Object.fromEntries(options.form) : options.form;
71
+ if (!hasType)
72
+ headers['content-type'] = 'application/x-www-form-urlencoded';
73
+ }
74
+ else if (options.multipart !== undefined) {
75
+ body = multipart(options.multipart);
76
+ if (!hasType)
77
+ headers['content-type'] = 'multipart/form-data';
78
+ }
79
+ else if (req)
80
+ body = describeBody(req.postData());
81
+ return { method, url, headers, body };
82
+ }
83
+ async function responseBody(res) {
84
+ const type = res.headers()['content-type'] ?? '';
85
+ const len = Number(res.headers()['content-length'] ?? 0);
86
+ if (type && !TEXT_TYPES.test(type))
87
+ return `<${type.split(';')[0]}${len ? ` ${len} bytes` : ''}>`;
88
+ try {
89
+ const buf = await res.body();
90
+ if (buf.length > MAX_BODY)
91
+ return buf.subarray(0, MAX_BODY).toString('utf8') + `\n… truncated (${buf.length} bytes)`;
92
+ const text = buf.toString('utf8');
93
+ if (!text)
94
+ return undefined;
95
+ try {
96
+ return JSON.parse(text);
97
+ }
98
+ catch {
99
+ return text;
100
+ }
101
+ }
102
+ catch {
103
+ return undefined;
104
+ }
105
+ }
106
+ async function record(call) {
107
+ const info = currentTest();
108
+ if (!info)
109
+ return;
110
+ try {
111
+ await info.attach(`${call.method} ${call.url}`, { body: JSON.stringify(call), contentType: 'application/x-rl-api' });
112
+ }
113
+ catch { /* test already finished */ }
114
+ }
115
+ /** Patch the shared APIRequestContext prototype (all contexts in this process) once. */
116
+ function patchContext(ctx) {
117
+ let proto = Object.getPrototypeOf(ctx);
118
+ while (proto && !Object.prototype.hasOwnProperty.call(proto, 'fetch'))
119
+ proto = Object.getPrototypeOf(proto);
120
+ if (!proto || proto[PATCHED])
121
+ return;
122
+ const original = proto.fetch;
123
+ proto.fetch = async function patchedFetch(urlOrRequest, options = {}) {
124
+ if (!currentTest())
125
+ return original.call(this, urlOrRequest, options);
126
+ const t0 = Date.now();
127
+ let req;
128
+ try {
129
+ req = requestFrom(urlOrRequest, options);
130
+ }
131
+ catch { /* record nothing */ }
132
+ let res;
133
+ let err;
134
+ try {
135
+ res = await original.call(this, urlOrRequest, options);
136
+ }
137
+ catch (e) {
138
+ err = e;
139
+ }
140
+ if (req) {
141
+ const call = { method: req.method, url: res ? res.url() : req.url, duration: Date.now() - t0, requestHeaders: req.headers, requestBody: req.body };
142
+ if (res) {
143
+ call.status = res.status();
144
+ call.responseHeaders = res.headers();
145
+ call.responseBody = await responseBody(res);
146
+ }
147
+ else
148
+ call.responseBody = 'Request failed: ' + (err instanceof Error ? err.message : String(err)).replace(/\u001b\[[0-9;]*m/g, '');
149
+ await record(call);
150
+ }
151
+ if (err)
152
+ throw err;
153
+ return res;
154
+ };
155
+ proto[PATCHED] = true;
156
+ }
157
+ function install() {
158
+ let api;
159
+ try {
160
+ api = pw().request;
161
+ }
162
+ catch {
163
+ return;
164
+ }
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;
177
+ };
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(() => { });
180
+ }
181
+ install();
package/dist/meta.d.ts CHANGED
@@ -30,7 +30,10 @@ export declare function testData(data: unknown, name?: string): Promise<void>;
30
30
  * await api({ method: 'POST', url: '/v1/orders', status: 201, duration: 138, requestBody, responseBody });
31
31
  */
32
32
  export declare function api(call: ApiCall): Promise<void>;
33
- /** Wrap a Playwright APIRequestContext call so it's recorded automatically. */
33
+ /**
34
+ * @deprecated Import `test` from 'reporting-labs/test' instead: every `request.*` call is then recorded
35
+ * without a wrapper. Kept for projects that already use it.
36
+ */
34
37
  export declare function recordApi<T extends {
35
38
  status(): number;
36
39
  headers(): Record<string, string>;
package/dist/meta.js CHANGED
@@ -56,7 +56,10 @@ async function testData(data, name = 'Test data') {
56
56
  async function api(call) {
57
57
  await info().attach(call.name ?? `${call.method.toUpperCase()} ${call.url}`, { body: JSON.stringify(call), contentType: 'application/x-rl-api' });
58
58
  }
59
- /** Wrap a Playwright APIRequestContext call so it's recorded automatically. */
59
+ /**
60
+ * @deprecated Import `test` from 'reporting-labs/test' instead: every `request.*` call is then recorded
61
+ * without a wrapper. Kept for projects that already use it.
62
+ */
60
63
  async function recordApi(method, url, options, run) {
61
64
  const t0 = Date.now();
62
65
  const res = await run();
@@ -27,7 +27,9 @@ export default class ReportingLabsReporter implements Reporter {
27
27
  private toDataBlock;
28
28
  private loadHistory;
29
29
  private dimensions;
30
- /** Pull dimension values from annotations and tags. */
30
+ /** Keys picked up from tags and annotations even when they are not breakdown dimensions. */
31
+ private metaKeys;
32
+ /** Pull meta values (priority, owner, story, epic...) from annotations and tags. */
31
33
  private extractMeta;
32
34
  private rel;
33
35
  private serializeError;
package/dist/reporter.js CHANGED
@@ -40,6 +40,12 @@ const child_process_1 = require("child_process");
40
40
  const template_1 = require("./template");
41
41
  const mask_1 = require("./mask");
42
42
  const DEFAULT_EMBED_LIMIT = 2 * 1024 * 1024;
43
+ /** Attach steps created by log() and the automatic API capture; the data shows in its own section, so hide the step. */
44
+ function isInternalAttach(s) {
45
+ return s.category === 'test.attach' && /^Attach "(rl:log|(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS) )/.test(s.title);
46
+ }
47
+ /** Meta keys shown on every test and turned into links, without being breakdown dimensions. */
48
+ const META_KEYS = ['priority', 'severity', 'feature', 'owner', 'epic', 'story', 'issue', 'bug', 'component', 'module', 'team', 'sprint', 'testcase', 'tms', 'requirement'];
43
49
  class ReportingLabsReporter {
44
50
  constructor(options = {}) {
45
51
  this.startTime = Date.now();
@@ -295,15 +301,15 @@ class ReportingLabsReporter {
295
301
  dimensions() {
296
302
  return (this.options.dimensions ?? ['priority', 'severity', 'feature', 'owner']).map(d => d.toLowerCase());
297
303
  }
298
- /** Pull dimension values from annotations and tags. */
304
+ /** Keys picked up from tags and annotations even when they are not breakdown dimensions. */
305
+ metaKeys() {
306
+ return [...new Set([...this.dimensions(), ...META_KEYS, ...Object.keys(this.options.links ?? {}).map(k => k.toLowerCase())])].filter(k => k !== '*');
307
+ }
308
+ /** Pull meta values (priority, owner, story, epic...) from annotations and tags. */
299
309
  extractMeta(test) {
300
- const dims = this.dimensions();
310
+ const dims = this.metaKeys();
301
311
  const meta = {};
302
- for (const a of test.annotations) {
303
- const k = a.type.toLowerCase();
304
- if (dims.includes(k) && a.description)
305
- meta[k] = a.description;
306
- }
312
+ // Tags first (describe-level, then test-level), annotations last so a test can override its describe's tags.
307
313
  for (const raw of test.tags) {
308
314
  const tag = raw.replace(/^@/, '');
309
315
  const m = tag.match(/^([a-z_-]+)[:=](.+)$/i);
@@ -316,6 +322,11 @@ class ReportingLabsReporter {
316
322
  if (/^(blocker|critical|major|minor|trivial)$/i.test(tag) && dims.includes('severity') && !meta.severity)
317
323
  meta.severity = tag.toLowerCase();
318
324
  }
325
+ for (const a of test.annotations) {
326
+ const k = a.type.toLowerCase();
327
+ if (dims.includes(k) && a.description)
328
+ meta[k] = a.description;
329
+ }
319
330
  return meta;
320
331
  }
321
332
  rel(file) {
@@ -395,6 +406,14 @@ class ReportingLabsReporter {
395
406
  data.push(this.toDataBlock(a.name, JSON.stringify({ csv: body.toString() })));
396
407
  continue;
397
408
  }
409
+ // Plain test.info().attach(name, { body: JSON.stringify(x), contentType: 'application/json' }) renders like testData().
410
+ if (body && a.contentType === 'application/json' && body.length <= 512 * 1024) {
411
+ const block = this.toDataBlock(a.name, body.toString());
412
+ if (block.kind !== 'text') {
413
+ data.push(block);
414
+ continue;
415
+ }
416
+ }
398
417
  normal.push(a);
399
418
  }
400
419
  return {
@@ -405,10 +424,10 @@ class ReportingLabsReporter {
405
424
  startTime: r.startTime.getTime(),
406
425
  workerIndex: r.parallelIndex,
407
426
  errors: r.errors.map(e => this.serializeError(e)),
408
- steps: r.steps.map(s => this.serializeStep(s)),
427
+ steps: r.steps.filter(s => !isInternalAttach(s)).map(s => this.serializeStep(s)),
409
428
  attachments: normal.map(a => this.serializeAttachment(a, test)).filter(Boolean),
410
- stdout: r.stdout.map(c => stripAnsi(c.toString())),
411
- stderr: r.stderr.map(c => stripAnsi(c.toString())),
429
+ stdout: r.stdout.map(c => this.masker.maskStr(stripAnsi(c.toString()))),
430
+ stderr: r.stderr.map(c => this.masker.maskStr(stripAnsi(c.toString()))),
412
431
  };
413
432
  }
414
433
  serializeStep(s) {
@@ -417,7 +436,7 @@ class ReportingLabsReporter {
417
436
  category: s.category,
418
437
  duration: s.duration,
419
438
  error: s.error?.message ? stripAnsi(s.error.message) : undefined,
420
- steps: s.steps.map(c => this.serializeStep(c)),
439
+ steps: s.steps.filter(c => !isInternalAttach(c)).map(c => this.serializeStep(c)),
421
440
  };
422
441
  }
423
442
  serializeAttachment(a, test) {
package/dist/template.js CHANGED
@@ -414,6 +414,7 @@ li.collapsed>ul{display:none}
414
414
  .folder .cnt{margin-left:auto;display:flex;gap:6px} .folder .cnt b{color:var(--fail)} .folder .cnt span{color:var(--ink-3)}
415
415
  .folder .tw{font-size:9px;color:var(--ink-3)}
416
416
  .folder+.folder,.folder+.file{margin-left:0}
417
+ .suite{display:flex;align-items:center;gap:6px;padding:7px 12px 3px;font-size:12px;color:var(--ink-2)} .suite .sn{font-weight:500;color:var(--ink)} .suite .tw{font-size:9px;color:var(--ink-3)} .suite .cnt{margin-left:auto;display:flex;gap:6px} .suite .cnt b{color:var(--fail)} .suite .cnt span{color:var(--ink-3)}
417
418
  .tree-indent{padding-left:14px;border-left:1px solid var(--line);margin-left:16px}
418
419
  .file-row{display:flex;align-items:center;gap:6px;padding:6px 12px 2px;font:12px var(--mono);color:var(--ink-3);width:100%;text-align:left}
419
420
  .file-row .mini{display:flex;gap:2px;margin-left:auto} .file-row .mini i{width:6px;height:6px;border-radius:50%;display:block}
@@ -424,7 +425,7 @@ li.collapsed>ul{display:none}
424
425
  .logs{background:var(--surface-2);border-radius:var(--radius);padding:8px 0;font:12px/1.55 var(--mono);max-height:320px;overflow:auto}
425
426
  .logs .ln{display:grid;grid-template-columns:78px 1fr;gap:12px;padding:1px 12px}
426
427
  .logs .ln:hover{background:var(--surface)} .logs .ts{color:var(--ink-3)} .logs .lm{white-space:pre-wrap;word-break:break-word}
427
- .logs .ln.err .lm{color:var(--fail)} .logs .ln.warn .lm{color:#9A6A00}
428
+ .logs .ln.err .lm{color:var(--fail)} .logs .ln.warn .lm{color:#9A6A00} .logs.plain .ln{grid-template-columns:1fr}
428
429
  /* data tables */
429
430
  .tbl-wrap{overflow:auto;border:1px solid var(--line);border-radius:var(--radius);max-height:360px}
430
431
  table.tbl{border-collapse:collapse;font-size:12.5px;width:100%;min-width:100%}
@@ -447,7 +448,8 @@ table.tbl{border-collapse:collapse;font-size:12.5px;width:100%;min-width:100%}
447
448
  .api-col{padding:10px 12px;min-width:0} .api-col+.api-col{border-left:1px solid var(--line)}
448
449
  .api-col h5{margin:0 0 6px;font-size:11px;font-weight:600;color:var(--ink-3)}
449
450
  .api-col pre{margin:0 0 10px;background:var(--surface-2);padding:8px 10px;border-radius:4px;font:11.5px/1.5 var(--mono);white-space:pre-wrap;word-break:break-all;max-height:260px;overflow:auto}
450
- .api.collapsed .api-body{display:none}
451
+ .api.collapsed .api-body,.api.collapsed .api-tools{display:none}
452
+ .api-tools{display:flex;gap:8px;padding:8px 12px;border-top:1px solid var(--line);background:var(--surface)} .api-tools .btn{padding:5px 10px;cursor:pointer} .api-tools .btn.done{color:var(--pass);border-color:var(--pass)}
451
453
  /* bdd */
452
454
  .step .kw{font-weight:600;color:var(--accent);margin-right:4px} .step .kw.and{color:var(--ink-3)}
453
455
  .badge.scenario{background:var(--surface-2);color:var(--ink-2)}
@@ -767,7 +769,7 @@ function failuresView(){
767
769
  }
768
770
  function apiView(){
769
771
  const calls=[]; for(const t of data.tests) for(const r of t.results) for(const c of r.api) calls.push({c,t,r});
770
- if(!calls.length) return h('div',{class:'card'},'No API calls recorded. Use api() or recordApi() from reporting-labs inside your tests.');
772
+ if(!calls.length) return h('div',{class:'card'},h('b',{},'No API calls recorded.'),' Add ',h('code',{},"import 'reporting-labs/auto'"),' to playwright.config.ts and every request.get / request.post / page.request call shows up here with headers, bodies and a cURL command. Calls made with other clients can be recorded with api().');
771
773
  const bad=calls.filter(x=>(x.c.status||0)>=400).length, avg=Math.round(calls.reduce((a,x)=>a+(x.c.duration||0),0)/calls.length), slow=calls.filter(x=>(x.c.duration||0)>1000).length;
772
774
  calls.sort((a,b)=>((b.c.status||0)>=400)-((a.c.status||0)>=400)||(b.c.duration||0)-(a.c.duration||0));
773
775
  const byHost=new Map(); for(const x of calls){ try{ const hst=new URL(x.c.url).host; byHost.set(hst,(byHost.get(hst)||0)+1);}catch(e){} }
@@ -1252,14 +1254,27 @@ function refresh(){
1252
1254
  const box=$('#items'); box.innerHTML='';
1253
1255
  if(!vis.length){ box.append(h('div',{class:'empty'},'No tests match. Clear the search or pick another filter.')); return; }
1254
1256
  document.querySelectorAll('.seg button').forEach(b=>b.setAttribute('aria-pressed',b.dataset.g===state.group));
1255
- const item=t=>h('button',{class:'item','data-id':t.id,'aria-current':state.selected===t.id,onclick:()=>select(t.id)},
1257
+ const item=(t,inGroup)=>h('button',{class:'item','data-id':t.id,'aria-current':state.selected===t.id,onclick:()=>select(t.id)},
1256
1258
  h('span',{class:'st '+t.outcome}),
1257
- h('span',{class:'tt'}, t.path.length?h('div',{class:'p'},t.path.join(' › ')):null, h('div',{class:'n'},t.title), h('div',{class:'d'}, ms(t.duration)+(t.results.length>1?' · '+t.results.length+' attempts':'')+(data.projects.length>1?' · '+t.project:'')+(t.outcome==='skipped'&&skipReason(t)?' · '+skipReason(t):'')+(t.expectedFailure?' · expected failure':''))));
1259
+ h('span',{class:'tt'}, t.path.length&&!inGroup?h('div',{class:'p'},t.path.join(' › ')):null, h('div',{class:'n'},t.title), h('div',{class:'d'}, ms(t.duration)+(t.results.length>1?' · '+t.results.length+' attempts':'')+(data.projects.length>1?' · '+t.project:'')+(t.outcome==='skipped'&&skipReason(t)?' · '+skipReason(t):'')+(t.expectedFailure?' · expected failure':''))));
1258
1260
  const counts=ts=>{ const f=ts.filter(t=>isFail(t.outcome)).length; return h('span',{class:'cnt'}, f?h('b',{},f+' ✕'):null, h('span',{},ts.length)); };
1261
+ // describe blocks: a header per level, tests indented under it
1262
+ const grouped=(ts,indent)=>{
1263
+ const out=[]; let last=[];
1264
+ for(const t of ts){
1265
+ const p=t.path; let i=0; while(i<p.length && i<last.length && p[i]===last[i]) i++;
1266
+ for(let d=i; d<p.length; d++){
1267
+ const inSuite=ts.filter(x=>x.path.length>d && p.slice(0,d+1).every((seg,k)=>x.path[k]===seg));
1268
+ out.push(h('div',{class:'suite',style:'padding-left:'+(indent+d*14)+'px'}, h('span',{class:'tw'},'▾'), h('span',{class:'sn'},p[d]), counts(inSuite)));
1269
+ }
1270
+ const el=item(t,true); el.style.paddingLeft=(indent+p.length*14)+'px'; out.push(el); last=p;
1271
+ }
1272
+ return out;
1273
+ };
1259
1274
  if(state.group==='flat'){ for(const t of vis) box.append(item(t)); }
1260
1275
  else if(state.group==='file'){
1261
- let lastFile=null;
1262
- for(const t of vis){ if(t.file!==lastFile){ box.append(h('div',{class:'file'},t.file)); lastFile=t.file; } box.append(item(t)); }
1276
+ const byF=new Map(); for(const t of vis){ if(!byF.has(t.file)) byF.set(t.file,[]); byF.get(t.file).push(t); }
1277
+ for(const [f,ts] of byF){ box.append(h('div',{class:'file'},f)); for(const el of grouped(ts,12)) box.append(el); }
1263
1278
  } else {
1264
1279
  // folder tree
1265
1280
  const root={dirs:new Map(),files:new Map()};
@@ -1272,7 +1287,7 @@ function refresh(){
1272
1287
  if(open) out.push(...render(child,key,depth+1)); }
1273
1288
  for(const [name,ts] of [...node.files.entries()].sort()){ const key=path+name; const open=state.open[key]!==false;
1274
1289
  out.push(h('button',{class:'file-row',style:'padding-left:'+(12+depth*14)+'px',onclick:()=>{state.open[key]=!open;refresh();}}, h('span',{class:'tw'},open?'▾':'▸'), name, h('span',{class:'mini'}, ts.map(t=>h('i',{style:'background:'+colorOf(bucket(t))})))));
1275
- if(open) for(const t of ts){ const el=item(t); el.style.paddingLeft=(24+depth*14)+'px'; out.push(el); } }
1290
+ if(open) out.push(...grouped(ts,24+depth*14)); }
1276
1291
  return out;
1277
1292
  };
1278
1293
  for(const el of render(root,'',0)) box.append(el);
@@ -1297,7 +1312,7 @@ function renderDetail(t){
1297
1312
  const metaKeys=Object.keys(t.meta);
1298
1313
  const linkFor=(k,v)=>{ const tpl=data.options.links[k]||data.options.links['*']; if(tpl) return tpl.replace('{id}',encodeURIComponent(v)); if(/^https?:\/\//.test(v)) return v; return null; };
1299
1314
  if(metaKeys.length) d.append(h('div',{class:'metas'}, metaKeys.map(k=>{ const v=t.meta[k], low=/^(P[3-4]|low|minor|trivial|normal|medium)$/i.test(v), href=linkFor(k,v); return h('span',{class:'meta '+k+(low?' low':'')}, h('span',{class:'k'},k), href? h('a',{href,target:'_blank',rel:'noopener'},v) : h('span',{class:'v'},v)); })));
1300
- const otherAnn=t.annotations.filter(a=>!DIMS.includes(a.type.toLowerCase()));
1315
+ const otherAnn=t.annotations.filter(a=>!DIMS.includes(a.type.toLowerCase()) && !(a.type.toLowerCase() in t.meta));
1301
1316
  if(otherAnn.length) d.append(h('h4',{},'Annotations'), h('dl',{class:'kv'}, otherAnn.map(a=>[h('dt',{},a.type),h('dd',{},a.description||'')])));
1302
1317
  if(t.results.length>1){
1303
1318
  d.append(h('div',{class:'tabs'}, t.results.map((r,i)=>h('button',{class:'tab','aria-selected':state.retry===i,onclick:()=>{state.retry=i;renderDetail(t);}}, (i===0?'Attempt 1':'Retry '+i)+' · '+(label[r.status]||r.status)))));
@@ -1329,9 +1344,10 @@ function renderDetail(t){
1329
1344
  h('div',{class:'trace-how'}, 'Open with ', h('code',{},'npx playwright show-trace '+a.src), ' or drop the file on ', h('a',{href:'https://trace.playwright.dev',target:'_blank',rel:'noopener'},'trace.playwright.dev')),
1330
1345
  h('a',{class:'dl',href:a.src,download:''},'Download trace'))))); }
1331
1346
  if(files.length){ body.append(h('h4',{},'Files'), h('div',{class:'att'}, files.map(a=>h('figure',{}, h('div',{class:'file'}, h('a',{href:a.src,download:''},a.name), h('div',{style:'font-size:11px;color:var(--ink-3)'},a.contentType+(a.size?' · '+kb(a.size):''))))))); }
1332
- for(const a of texts) body.append(h('h4',{},a.name), h('pre',{class:'txt'},a.text));
1333
- if(r.stdout.length) body.append(h('h4',{},'Console output'), h('pre',{class:'txt'},r.stdout.join('')));
1334
- if(r.stderr.length) body.append(h('h4',{},'Console errors'), h('pre',{class:'txt'},r.stderr.join('')));
1347
+ for(const a of texts){ if(/^error-context/i.test(a.name)) body.append(h('details',{class:'errfull'}, h('summary',{},'Error context (written by Playwright for AI tools)'), h('pre',{class:'txt'},a.text))); else body.append(h('h4',{},a.name), h('pre',{class:'txt'},a.text)); }
1348
+ const conLines=(arr,forceErr)=>arr.join('').split('\n').filter(x=>x.trim()).map(x=>h('div',{class:'ln'+(forceErr||/\b(error|fail|exception)\b/i.test(x)?' err':/\bwarn/i.test(x)?' warn':'')}, h('span',{class:'lm'},x)));
1349
+ if(r.stdout.length) body.append(h('h4',{},'Console output', h('span',{class:'hint'},'console.log in this test')), h('div',{class:'logs plain'}, conLines(r.stdout,false)));
1350
+ if(r.stderr.length) body.append(h('h4',{},'Console errors', h('span',{class:'hint'},'console.error in this test')), h('div',{class:'logs plain'}, conLines(r.stderr,true)));
1335
1351
  if(!body.children.length) body.append(h('p',{style:'color:var(--ink-3)'}, r.status==='skipped'?'Skipped — nothing was executed.'+(skipReason(t)?' Reason: '+skipReason(t):''):'Passed with no steps or attachments recorded.'));
1336
1352
  d.append(body);
1337
1353
  }
@@ -1359,10 +1375,24 @@ function apiPanel(c){
1359
1375
  const head=h('button',{class:'api-head',onclick:()=>wrap.classList.toggle('collapsed')}, h('span',{class:'m '+c.method.toUpperCase()},c.method.toUpperCase()), h('span',{class:'u',title:c.url},c.url), st?h('span',{class:'sc '+cls},st):null, c.duration!=null?h('span',{class:'d'},ms(c.duration)):null);
1360
1376
  const pre=v=>v==null?null:h('pre',{}, typeof v==='string'?v:JSON.stringify(v,null,2));
1361
1377
  const col=(title,headers,bodyv)=>h('div',{class:'api-col'}, h('h5',{},title), headers&&Object.keys(headers).length? [h('h5',{},'Headers'), pre(headers)] : null, bodyv!=null? [h('h5',{},'Body'), pre(bodyv)] : h('div',{style:'font-size:12px;color:var(--ink-3)'},'no body'));
1362
- wrap.append(head, h('div',{class:'api-body'}, col('Request',c.requestHeaders,c.requestBody), col('Response',c.responseHeaders,c.responseBody)));
1378
+ const tools=h('div',{class:'api-tools'}, h('button',{class:'btn',onclick:e=>copyText(curlOf(c),e.currentTarget,'Copied')},'Copy as cURL'), h('button',{class:'btn',onclick:e=>copyText(c.url,e.currentTarget,'Copied')},'Copy URL'));
1379
+ wrap.append(head, h('div',{class:'api-body'}, col('Request',c.requestHeaders,c.requestBody), col('Response',c.responseHeaders,c.responseBody)), tools);
1363
1380
  if(cls==='bad') wrap.classList.remove('collapsed');
1364
1381
  return wrap;
1365
1382
  }
1383
+ function curlOf(c){
1384
+ const q=v=>"'"+String(v).replace(/'/g,"'\\''")+"'";
1385
+ const hdr=c.requestHeaders||{}, type=Object.keys(hdr).filter(k=>k.toLowerCase()==='content-type').map(k=>hdr[k])[0]||'';
1386
+ const parts=['curl -X '+c.method.toUpperCase()+' '+q(c.url)];
1387
+ for(const k of Object.keys(hdr)) parts.push('-H '+q(k+': '+hdr[k]));
1388
+ if(c.requestBody!=null){
1389
+ let b=c.requestBody;
1390
+ if(typeof b==='object' && /x-www-form-urlencoded/i.test(type)) b=Object.keys(b).map(k=>encodeURIComponent(k)+'='+encodeURIComponent(String(b[k]))).join('&');
1391
+ else if(typeof b!=='string') b=JSON.stringify(b);
1392
+ if(!/^<(binary|file|multipart)/.test(b)) parts.push('--data-raw '+q(b));
1393
+ }
1394
+ return parts.join(' \\\n ');
1395
+ }
1366
1396
  function kb(n){ return n<1024?n+' B':n<1048576?(n/1024).toFixed(0)+' KB':(n/1048576).toFixed(1)+' MB'; }
1367
1397
  function compare(set){
1368
1398
  const wrap=h('div',{class:'cmp'});
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reporting-labs",
3
- "version": "0.1.5",
3
+ "version": "0.2.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",
@@ -25,6 +25,10 @@
25
25
  "./playwright": {
26
26
  "types": "./dist/index.d.ts",
27
27
  "default": "./dist/index.js"
28
+ },
29
+ "./auto": {
30
+ "types": "./dist/auto.d.ts",
31
+ "default": "./dist/auto.js"
28
32
  }
29
33
  },
30
34
  "bin": {