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 +151 -158
- package/bin/cli.js +34 -21
- package/bin/config-template.txt +78 -0
- package/dist/auto.d.ts +1 -0
- package/dist/auto.js +181 -0
- package/dist/meta.d.ts +4 -1
- package/dist/meta.js +4 -1
- package/dist/reporter.d.ts +3 -1
- package/dist/reporter.js +30 -11
- package/dist/template.js +43 -13
- package/package.json +5 -1
package/README.md
CHANGED
|
@@ -2,15 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
# reportingLabs
|
|
4
4
|
|
|
5
|
-
**
|
|
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
|
|
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
|
-
|
|
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.
|
|
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: [
|
|
36
|
+
reporter: [
|
|
37
|
+
['list'],
|
|
38
|
+
['reporting-labs', reportingLabs], // <- add this line
|
|
39
|
+
],
|
|
26
40
|
});
|
|
27
41
|
```
|
|
28
42
|
|
|
29
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
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 {
|
|
63
|
-
import reportingLabs from './reporting-labs.config';
|
|
91
|
+
import { testData } from 'reporting-labs';
|
|
64
92
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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.
|
|
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
|
|
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) |
|
|
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
|
|
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
|
|
96
|
-
| [reporting-labs.config.ts](https://github.com/naveenautomationlabs/reporting-labs/blob/main/examples/reporting-labs.config.ts) | A complete
|
|
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
|
|
148
|
+
### Overview: the whole run on one screen
|
|
108
149
|
|
|
109
|
-
- **Tiles**: pass rate with a ring and the change from the
|
|
110
|
-
- **Run strip**: every test as one cell, in run order. Hover for the name, click to open.
|
|
111
|
-
- **Needs attention**: failures
|
|
112
|
-
- **Failure clusters**: failures grouped by error message
|
|
113
|
-
- **Breakdown**:
|
|
114
|
-
- **Slowest tests
|
|
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**:
|
|
124
|
-
- **Copy summary**: a Slack
|
|
125
|
-
- The table shows every failed or flaky test with its
|
|
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
|
|
170
|
+
### Test detail: the error, the steps, the proof
|
|
130
171
|
|
|
131
|
-
- **Expected vs received** side by side with the difference highlighted.
|
|
132
|
-
- Error **location** (
|
|
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
|
|
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
|
|
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`
|
|
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
|
|
192
|
-
| Are we
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
218
|
-
-
|
|
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
|
-
##
|
|
220
|
+
## All options
|
|
221
221
|
|
|
222
|
-
|
|
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'` |
|
|
227
|
-
| `logo` | – | Path or data URI of your logo
|
|
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` | `{}` |
|
|
230
|
-
| `env` | – | Extra rows
|
|
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
|
|
233
|
-
| `
|
|
234
|
-
| `
|
|
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'
|
|
239
|
-
| `accent` | palette accent |
|
|
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` |
|
|
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
|
|
244
|
+
| `outputFolder` | `'reporting-labs'` | Where the report goes |
|
|
245
245
|
| `outputFile` | `'index.html'` | Report file name |
|
|
246
|
-
| `embedAttachments` | `true` |
|
|
247
|
-
| `embedLimit` | 2 MB |
|
|
248
|
-
| `embedVideos` | `false` |
|
|
249
|
-
| `embedFonts` | `true` | Bundle
|
|
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
|
-
|
|
252
|
+
If your reporter list differs between CI and local, add the same line to both:
|
|
253
253
|
|
|
254
254
|
```ts
|
|
255
|
-
reporter:
|
|
256
|
-
|
|
257
|
-
|
|
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
|
|
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
|
|
264
|
+
What happens on its own in CI:
|
|
271
265
|
|
|
272
|
-
- The Environment card links the CI job and the commit
|
|
273
|
-
- The run is labelled with the CI run number in the history and the trend
|
|
274
|
-
- "Open in VS Code" links are off
|
|
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).
|
|
279
|
-
2. **Keep the history.** `reporting-labs.history.json`
|
|
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
|
|
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
|
-
- **
|
|
302
|
-
- **
|
|
303
|
-
- **Videos on macOS.** If the report
|
|
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
|
|
307
|
-
- **Themes.** Light and dark follow the OS
|
|
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
|
|
312
|
-
- AI summary
|
|
313
|
-
- Hosted history dashboard across branches
|
|
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
|
|
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
|
|
8
|
-
|
|
9
|
-
fs.
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
console.log(
|
|
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
|
|
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
|
-
|
|
30
|
-
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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();
|
package/dist/reporter.d.ts
CHANGED
|
@@ -27,7 +27,9 @@ export default class ReportingLabsReporter implements Reporter {
|
|
|
27
27
|
private toDataBlock;
|
|
28
28
|
private loadHistory;
|
|
29
29
|
private dimensions;
|
|
30
|
-
/**
|
|
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
|
-
/**
|
|
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.
|
|
310
|
+
const dims = this.metaKeys();
|
|
301
311
|
const meta = {};
|
|
302
|
-
|
|
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.
|
|
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
|
-
|
|
1262
|
-
for(const
|
|
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)
|
|
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
|
-
|
|
1334
|
-
if(r.
|
|
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
|
-
|
|
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.
|
|
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": {
|