reporting-labs 0.2.0 → 0.3.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 +3 -2
- package/bin/config-template.txt +2 -1
- package/dist/explain.d.ts +23 -0
- package/dist/explain.js +160 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +3 -1
- package/dist/reporter.d.ts +2 -0
- package/dist/reporter.js +23 -1
- package/dist/template.js +30 -11
- package/dist/types.d.ts +14 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -169,6 +169,7 @@ Run it two or three times to see the history features (new vs known failures, fl
|
|
|
169
169
|
|
|
170
170
|
### Test detail: the error, the steps, the proof
|
|
171
171
|
|
|
172
|
+
- **What went wrong, in plain words.** Above every error the report says what happened and what to check first: "Element not found: `#checkout` was not on the page within 5s", "Element covered by another element: a `<div class=overlay>` was on top", "Site unreachable", "Test timed out", "Assertion failed: expected "90", got "100"". Playwright's own message stays right below it, untouched. The same label shows in Needs attention and groups the Failure clusters.
|
|
172
173
|
- **Expected vs received** side by side with the difference highlighted.
|
|
173
174
|
- Error **location** (opens in VS Code), Playwright's **code snippet**, full message and stack.
|
|
174
175
|
- **Steps** with a bar per step showing its share of the test time. Given/When/Then titles are styled as Gherkin.
|
|
@@ -224,7 +225,7 @@ Every option is optional. `npx reporting-labs init` writes them all, with commen
|
|
|
224
225
|
| Option | Default | What it does |
|
|
225
226
|
|---|---|---|
|
|
226
227
|
| `title` | `'Test report'` | Title in the header |
|
|
227
|
-
| `logo` | – |
|
|
228
|
+
| `logo` | – | Your logo next to the title: `'logo.png'` (a file next to the config, embedded in the report) or an https URL |
|
|
228
229
|
| `project` | – | `{ name, version, team, url }` shown under the title |
|
|
229
230
|
| `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
231
|
| `env` | – | Extra rows on the Environment card |
|
|
@@ -232,7 +233,7 @@ Every option is optional. `npx reporting-labs init` writes them all, with commen
|
|
|
232
233
|
| `maskKeys` | `[]` | Extra keys to mask as `****` |
|
|
233
234
|
| `dimensions` | `['priority','severity','feature','owner']` | Meta keys that get charts and filters |
|
|
234
235
|
| `dimensionOrder` | P0…P4, blocker…trivial | Sort order per dimension |
|
|
235
|
-
| `widgets` | all on | Hide cards: `{ tags: false, timeline: false, ... }
|
|
236
|
+
| `widgets` | all on | Hide cards: `{ tags: false, timeline: false, ... }`. Failure clusters and the Trend chart are always shown |
|
|
236
237
|
| `sections` | `[]` | Extra HTML below the summary, e.g. release notes |
|
|
237
238
|
| `history` | `{ enabled: true, keep: 30 }` | Run history file; `file` sets a custom path |
|
|
238
239
|
| `palette` | `'lab'` | `'lab'` (blue), `'ocean'`, `'ember'`, `'mono'` |
|
package/bin/config-template.txt
CHANGED
|
@@ -9,7 +9,7 @@ const config: ReportingLabsOptions = {
|
|
|
9
9
|
|
|
10
10
|
// ── Look ─────────────────────────────────────────────────────────────────────
|
|
11
11
|
title: 'My app – regression', // shown in the header
|
|
12
|
-
// logo: '
|
|
12
|
+
// logo: 'logo.png', // your logo next to the title: a file next to this config (embedded), or an https URL
|
|
13
13
|
// palette: 'lab', // 'lab' (blue, default) | 'ocean' | 'ember' | 'mono'; viewers can switch
|
|
14
14
|
// accent: '#7C3AED', // your brand color instead of the palette accent
|
|
15
15
|
// theme: 'auto', // 'auto' (follows OS, default) | 'light' | 'dark'
|
|
@@ -54,6 +54,7 @@ const config: ReportingLabsOptions = {
|
|
|
54
54
|
// },
|
|
55
55
|
|
|
56
56
|
// ── Widgets on the overview (hide what you do not need) ──────────────────────
|
|
57
|
+
// Failure clusters and the Trend chart are always shown.
|
|
57
58
|
// widgets: {
|
|
58
59
|
// runStrip: true, // pass/fail strip under the header
|
|
59
60
|
// outcome: true, // outcome ring
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turns a raw Playwright error message into a short, plain-language explanation.
|
|
3
|
+
* Rule based, no network, no AI. The original message is always kept next to it.
|
|
4
|
+
*/
|
|
5
|
+
export type ErrorKind = 'not-found' | 'ambiguous' | 'not-visible' | 'blocked' | 'disabled' | 'detached' | 'wrong-element' | 'assertion' | 'visual' | 'navigation' | 'network' | 'api' | 'test-timeout' | 'hook-timeout' | 'closed' | 'script' | 'file' | 'thrown';
|
|
6
|
+
export interface ErrorExplain {
|
|
7
|
+
kind: ErrorKind;
|
|
8
|
+
/** Short label for a badge, e.g. "Element not found". */
|
|
9
|
+
label: string;
|
|
10
|
+
/** One plain sentence about what happened. */
|
|
11
|
+
summary: string;
|
|
12
|
+
/** What to check first. */
|
|
13
|
+
hint?: string;
|
|
14
|
+
/** The locator involved, when Playwright printed one. */
|
|
15
|
+
locator?: string;
|
|
16
|
+
/** The Playwright call that failed, e.g. locator.click or page.goto. */
|
|
17
|
+
action?: string;
|
|
18
|
+
/** The expect matcher, e.g. toHaveText. */
|
|
19
|
+
matcher?: string;
|
|
20
|
+
timeoutMs?: number;
|
|
21
|
+
url?: string;
|
|
22
|
+
}
|
|
23
|
+
export declare function explainError(raw: string): ErrorExplain | undefined;
|
package/dist/explain.js
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Turns a raw Playwright error message into a short, plain-language explanation.
|
|
4
|
+
* Rule based, no network, no AI. The original message is always kept next to it.
|
|
5
|
+
*/
|
|
6
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
|
+
exports.explainError = explainError;
|
|
8
|
+
const LABELS = {
|
|
9
|
+
'not-found': 'Element not found',
|
|
10
|
+
'ambiguous': 'Selector matches several elements',
|
|
11
|
+
'not-visible': 'Element not visible',
|
|
12
|
+
'blocked': 'Element covered by another element',
|
|
13
|
+
'disabled': 'Element disabled',
|
|
14
|
+
'detached': 'Element disappeared',
|
|
15
|
+
'wrong-element': 'Wrong element type',
|
|
16
|
+
'assertion': 'Assertion failed',
|
|
17
|
+
'visual': 'Screenshot mismatch',
|
|
18
|
+
'navigation': 'Page did not load',
|
|
19
|
+
'network': 'Site unreachable',
|
|
20
|
+
'api': 'API call failed',
|
|
21
|
+
'test-timeout': 'Test timed out',
|
|
22
|
+
'hook-timeout': 'Hook timed out',
|
|
23
|
+
'closed': 'Browser closed early',
|
|
24
|
+
'script': 'Error in test code',
|
|
25
|
+
'file': 'File not found',
|
|
26
|
+
'thrown': 'Test threw an error',
|
|
27
|
+
};
|
|
28
|
+
const secs = (ms) => (ms >= 1000 ? (ms / 1000).toFixed(ms % 1000 ? 1 : 0) + 's' : ms + 'ms');
|
|
29
|
+
const short = (s, n = 120) => (s.length > n ? s.slice(0, n - 1) + '…' : s);
|
|
30
|
+
function pick(msg, re) { const m = msg.match(re); return m ? m[1] : undefined; }
|
|
31
|
+
function explainError(raw) {
|
|
32
|
+
const msg = (raw || '').replace(/\u001b\[[0-9;]*m/g, '').trim();
|
|
33
|
+
if (!msg)
|
|
34
|
+
return undefined;
|
|
35
|
+
const first = msg.split('\n')[0].trim();
|
|
36
|
+
const out = (kind, summary, hint, extra = {}) => ({ kind, label: LABELS[kind], summary, hint, ...extra });
|
|
37
|
+
// Common pieces Playwright prints
|
|
38
|
+
const locator = pick(msg, /(?:waiting for|Locator:\s*|resolved to \d+ elements?:?\s*|locator\(['"]|)(locator\([^\n]*?\))(?:\s|$)/m) ?? pick(msg, /(?:waiting for|Locator:\s*)\s*(getBy\w+\([^\n]*?\)(?:\.\w+\([^\n]*?\))*)/m);
|
|
39
|
+
const timeoutMs = Number(pick(msg, /(?:Timeout|timeout of|Timed out)\s+(\d+)ms/i) ?? 0) || undefined;
|
|
40
|
+
const action = pick(first, /^(?:Error: |TimeoutError: )?([a-zA-Z]+\.[a-zA-Z]+):/);
|
|
41
|
+
const url = pick(msg, /(?:navigating to|at)\s+"?(https?:\/\/[^\s"]+)/);
|
|
42
|
+
// ── Whole-test and hook timeouts ─────────────────────────────────────────
|
|
43
|
+
let m = msg.match(/"(beforeAll|beforeEach|afterAll|afterEach)" hook timeout of (\d+)ms exceeded/);
|
|
44
|
+
if (m)
|
|
45
|
+
return out('hook-timeout', `The ${m[1]} hook took longer than ${secs(Number(m[2]))}.`, 'Look at what the hook does (login, seeding data, starting a server). Raise the hook timeout only if that work really needs more time.', { timeoutMs: Number(m[2]) });
|
|
46
|
+
m = msg.match(/Test timeout of (\d+)ms exceeded(?: while (?:running|tearing down) "(\w+)" hook)?/);
|
|
47
|
+
if (m) {
|
|
48
|
+
const ms = Number(m[1]);
|
|
49
|
+
if (m[2])
|
|
50
|
+
return out('hook-timeout', `The ${m[2]} hook did not finish within the test timeout of ${secs(ms)}.`, 'Check the steps in the hook. A slow login or a waiting call is the usual cause.', { timeoutMs: ms });
|
|
51
|
+
const where = action ? ` It was stuck in ${action}` + (locator ? ` on ${locator}` : '') + '.' : '';
|
|
52
|
+
return out('test-timeout', `The whole test took longer than ${secs(ms)}.${where}`, 'Find the slow step in the Steps list below. Raise timeout in playwright.config.ts only if the flow is really that long.', { timeoutMs: ms, action, locator });
|
|
53
|
+
}
|
|
54
|
+
// ── Strict mode ──────────────────────────────────────────────────────────
|
|
55
|
+
m = msg.match(/strict mode violation: (.+?) resolved to (\d+) elements/s);
|
|
56
|
+
if (m)
|
|
57
|
+
return out('ambiguous', `${short(m[1].trim())} matched ${m[2]} elements, Playwright needs exactly one.`, 'Make the selector more specific, or pick one with .first(), .nth(i) or a filter such as { hasText }.', { locator: m[1].trim() });
|
|
58
|
+
// ── Assertions (expect) ──────────────────────────────────────────────────
|
|
59
|
+
m = msg.match(/expect\((?:locator|page|received|value)\)\.(\w+)(?:\(\w*\))?/) ?? msg.match(/waiting for expect\((?:locator|page)\)\.(\w+)/);
|
|
60
|
+
if (m || /^Error: expect\(/.test(first) || /^Error: Timed out \d+ms waiting for expect/.test(first)) {
|
|
61
|
+
const matcher = m ? m[1] : undefined;
|
|
62
|
+
const expected = pick(msg, /^Expected(?:[^:\n]{0,40})?:[ \t]*(.+)$/m)?.trim();
|
|
63
|
+
const received = pick(msg, /^Received(?:[^:\n]{0,40})?:[ \t]*(.+)$/m)?.trim();
|
|
64
|
+
const notFound = /element\(s\) not found|locator resolved to 0 elements|not found/i.test(msg) && !/unexpected value/i.test(msg);
|
|
65
|
+
const base = { matcher, locator, timeoutMs };
|
|
66
|
+
if (matcher === 'toHaveScreenshot' || /Screenshot comparison failed|pixels \(ratio [\d.]+ of all image pixels\) are different/.test(msg)) {
|
|
67
|
+
const px = pick(msg, /(\d+) pixels \(ratio ([\d.]+)/);
|
|
68
|
+
return out('visual', px ? `The page looks different from the baseline: ${px} pixels changed.` : 'The page looks different from the baseline screenshot.', 'Open the visual comparison below. If the new look is intended, update the baseline with --update-snapshots.', base);
|
|
69
|
+
}
|
|
70
|
+
if (notFound && locator)
|
|
71
|
+
return out('not-found', `${locator} was not on the page${timeoutMs ? ' within ' + secs(timeoutMs) : ''}, so ${matcher ?? 'the check'} could not run.`, 'Check the selector, and whether the element is inside an iframe, behind a login, or only shown after a click.', base);
|
|
72
|
+
if (matcher === 'toBeVisible' || (matcher === 'toBeHidden')) {
|
|
73
|
+
const want = matcher === 'toBeVisible' ? 'visible' : 'hidden';
|
|
74
|
+
return out(matcher === 'toBeVisible' ? 'not-visible' : 'assertion', `${locator ?? 'The element'} was expected to be ${want}${timeoutMs ? ' within ' + secs(timeoutMs) : ''} but was ${received ?? (want === 'visible' ? 'hidden' : 'visible')}.`, want === 'visible' ? 'The element may still be loading, be hidden by CSS, or sit inside a closed menu or dialog.' : 'Something kept the element on screen. Check the step that should hide it.', base);
|
|
75
|
+
}
|
|
76
|
+
if (matcher && /^(toHaveURL)$/.test(matcher))
|
|
77
|
+
return out('assertion', `The page URL was ${received ?? 'different'}, expected ${expected ?? 'another URL'}.`, 'The navigation may not have happened yet, or it went to a different page (a redirect, a login screen, an error page).', base);
|
|
78
|
+
if (matcher && /^(toHaveCount)$/.test(matcher))
|
|
79
|
+
return out('assertion', `${locator ?? 'The selector'} matched ${received ?? 'a different number of'} elements, expected ${expected ?? 'another count'}.`, 'The list may not have finished loading, or the selector also matches other elements.', base);
|
|
80
|
+
if (matcher && /^(toBeEnabled|toBeDisabled|toBeChecked|toBeEditable|toBeFocused|toBeEmpty|toBeAttached|toBeInViewport)$/.test(matcher))
|
|
81
|
+
return out('assertion', `${locator ?? 'The element'} was not in the expected state (${matcher.replace(/^toBe/, '').toLowerCase()})${received ? ': it was ' + received : ''}.`, 'Check the step before this one. The element may still be loading or waiting on a previous action.', base);
|
|
82
|
+
if (expected !== undefined && received !== undefined) {
|
|
83
|
+
const what = matcher && /text/i.test(matcher) ? 'text' : matcher && /value/i.test(matcher) ? 'value' : matcher && /attribute/i.test(matcher) ? 'attribute' : matcher && /title/i.test(matcher) ? 'title' : matcher && /class/i.test(matcher) ? 'class' : 'value';
|
|
84
|
+
const who = locator ? locator + ' had the wrong ' + what : 'The ' + what + ' was wrong';
|
|
85
|
+
return out('assertion', `${who}: expected ${short(expected, 80)}, got ${short(received, 80)}.`, /^["']/.test(expected) && /^["']/.test(received) && expected.toLowerCase() === received.toLowerCase() ? 'Only the letter case differs. Use toHaveText with { ignoreCase: true } if that is fine.' : 'Compare expected and received below. A copy change, a data change or a timing issue are the usual causes.', base);
|
|
86
|
+
}
|
|
87
|
+
return out('assertion', `${matcher ? 'expect().' + matcher + '()' : 'An expect()'} did not pass${locator ? ' for ' + locator : ''}.`, 'See the full message below for the expected and received values.', base);
|
|
88
|
+
}
|
|
89
|
+
// ── Navigation and network ───────────────────────────────────────────────
|
|
90
|
+
m = msg.match(/net::(ERR_[A-Z_]+)|NS_ERROR_([A-Z_]+)|Could not connect to server|ECONNREFUSED|ENOTFOUND|EAI_AGAIN|ETIMEDOUT|ECONNRESET|certificate|ERR_CERT/i);
|
|
91
|
+
if (m) {
|
|
92
|
+
const code = (m[1] || m[2] || m[0]).toUpperCase();
|
|
93
|
+
const reason = /REFUSED/.test(code) ? 'nothing is listening on that address' : /NOT_RESOLVED|ENOTFOUND|EAI_AGAIN/.test(code) ? 'the host name could not be resolved' : /CERT/i.test(code) ? 'the TLS certificate was rejected' : /TIMED_OUT|ETIMEDOUT/.test(code) ? 'the connection timed out' : /RESET|ABORTED/.test(code) ? 'the connection was dropped' : 'the connection failed';
|
|
94
|
+
if (action && /^apiRequestContext\./.test(action))
|
|
95
|
+
return out('api', `The API request could not be sent: ${reason} (${code}).`, 'Check the base URL and that the API is up. In CI, check that the service started before the tests.', { action, url });
|
|
96
|
+
return out('network', `The browser could not reach ${url ?? 'the site'}: ${reason} (${code}).`, 'Check baseURL, that the app is running, and VPN or proxy settings. In CI, make sure the web server starts before the tests.', { action, url });
|
|
97
|
+
}
|
|
98
|
+
if (action && /^apiRequestContext\./.test(action)) {
|
|
99
|
+
if (/Request timed out|Timeout \d+ms exceeded/.test(msg))
|
|
100
|
+
return out('api', `The API request did not answer${timeoutMs ? ' within ' + secs(timeoutMs) : ' in time'}.`, 'The API may be slow or hanging. Check its logs, or raise the request timeout.', { action, url, timeoutMs });
|
|
101
|
+
if (/Request context disposed/.test(msg))
|
|
102
|
+
return out('api', 'The API request was made after its request context was closed.', 'Make sure request.dispose() or the end of the test does not run before this call.', { action });
|
|
103
|
+
return out('api', `The API call ${action} failed: ${short(first.replace(/^Error: /, ''))}`, 'See the full message below and the API tab for the request.', { action, url });
|
|
104
|
+
}
|
|
105
|
+
if (action && /^(page|frame)\.(goto|reload|goBack|goForward|waitForURL|waitForLoadState|waitForNavigation)$/.test(action)) {
|
|
106
|
+
if (/Timeout \d+ms exceeded|Navigation timeout/i.test(msg))
|
|
107
|
+
return out('navigation', `${url ?? 'The page'} did not finish loading within ${timeoutMs ? secs(timeoutMs) : 'the timeout'}.`, 'The app may be slow, stuck on a request, or redirecting in a loop. Try waitUntil: "domcontentloaded" if the page keeps long-running requests open.', { action, url, timeoutMs });
|
|
108
|
+
if (/interrupted by another navigation/.test(msg))
|
|
109
|
+
return out('navigation', 'The page navigated somewhere else while this navigation was in progress.', 'A redirect or a click started another navigation. Wait for the final URL instead.', { action, url });
|
|
110
|
+
return out('navigation', `${action} failed: ${short(first.replace(/^(Error|TimeoutError): /, ''))}`, 'See the full message below.', { action, url });
|
|
111
|
+
}
|
|
112
|
+
if (/Navigation timeout of \d+ms exceeded|page\.waitForNavigation/.test(msg))
|
|
113
|
+
return out('navigation', `The page did not finish loading within ${timeoutMs ? secs(timeoutMs) : 'the timeout'}.`, 'The app may be slow or stuck on a request. Check the Network tab in a trace.', { url, timeoutMs });
|
|
114
|
+
// ── Closed / detached ────────────────────────────────────────────────────
|
|
115
|
+
if (/Target page, context or browser has been closed|Target closed|Browser has been closed|Browser closed|browser has disconnected|Page closed|Context closed/i.test(msg))
|
|
116
|
+
return out('closed', 'The browser or page was closed before this step could run.', 'A previous step closed it, the test ended early, or the browser crashed. Look at the step just before this one.', { action });
|
|
117
|
+
if (/Execution context was destroyed|most likely because of a navigation/.test(msg))
|
|
118
|
+
return out('detached', 'The page navigated away while this step was running.', 'Wait for the navigation to finish (for example await page.waitForURL) before touching the page.', { action, locator });
|
|
119
|
+
if (/not attached to the DOM|element was detached|Element is not attached/i.test(msg))
|
|
120
|
+
return out('detached', `${locator ?? 'The element'} was removed from the page while Playwright was using it.`, 'The UI re-rendered the element. Re-locate it after the change, or wait for the update to finish.', { action, locator });
|
|
121
|
+
// ── Action timeouts on a locator: not found / not visible / blocked / disabled ──
|
|
122
|
+
if (action && /Timeout \d+ms exceeded/.test(msg)) {
|
|
123
|
+
const base = { action, locator, timeoutMs };
|
|
124
|
+
const t = timeoutMs ? ' for ' + secs(timeoutMs) : '';
|
|
125
|
+
if (/intercepts pointer events/.test(msg)) {
|
|
126
|
+
const by = pick(msg, /\n\s*-?\s*(<[^>]+>)[^\n]*intercepts pointer events/);
|
|
127
|
+
return out('blocked', `${locator ?? 'The element'} was there, but ${by ? by + ' ' : 'another element '}was covering it, so the ${action.split('.')[1]} never landed.`, 'A modal, cookie banner, toast or loading overlay is on top. Close it first, or wait for it to disappear.', base);
|
|
128
|
+
}
|
|
129
|
+
if (/element is not visible/.test(msg))
|
|
130
|
+
return out('not-visible', `${locator ?? 'The element'} exists but stayed hidden${t}.`, 'It may be inside a closed menu, collapsed section or hidden tab, or hidden by CSS. Open the container first.', base);
|
|
131
|
+
if (/element is not enabled|is disabled/.test(msg))
|
|
132
|
+
return out('disabled', `${locator ?? 'The element'} stayed disabled${t}.`, 'A form may be invalid or still loading. Fill the required fields or wait for the button to enable.', base);
|
|
133
|
+
if (/element is outside of the viewport/.test(msg))
|
|
134
|
+
return out('not-visible', `${locator ?? 'The element'} was outside the visible area${t}.`, 'Scroll it into view, or check for a fixed layout that keeps it off screen.', base);
|
|
135
|
+
if (/not an? <input>|not an <input>|Element is not an/.test(msg))
|
|
136
|
+
return out('wrong-element', `${locator ?? 'The element'} is not the kind of element this action works on.`, 'For example fill() needs an <input> or <textarea>. Check the selector points at the right element.', base);
|
|
137
|
+
if (/waiting for/.test(msg) && !/locator resolved to/.test(msg))
|
|
138
|
+
return out('not-found', `${locator ?? 'The element'} was not on the page${timeoutMs ? ' within ' + secs(timeoutMs) : ''}, so ${action} could not run.`, 'Check the selector. The element may be inside an iframe, behind a login, or only shown after another step.', base);
|
|
139
|
+
if (/waiting for element to be visible, enabled and stable/.test(msg))
|
|
140
|
+
return out('not-visible', `${locator ?? 'The element'} was found but never became ready (visible, enabled and stable)${timeoutMs ? ' within ' + secs(timeoutMs) : ''}.`, 'The element may be animating, hidden, or disabled. Wait for the animation or the loading state to finish.', base);
|
|
141
|
+
return out('not-found', `${action} did not complete${timeoutMs ? ' within ' + secs(timeoutMs) : ''}${locator ? ' on ' + locator : ''}.`, 'See the call log below for what Playwright was waiting on.', base);
|
|
142
|
+
}
|
|
143
|
+
if (action && /Element is not an? <input>|not an <input>/.test(msg))
|
|
144
|
+
return out('wrong-element', `${locator ?? 'The element'} is not the kind of element this action works on.`, 'For example fill() needs an <input> or <textarea>. Check the selector.', { action, locator });
|
|
145
|
+
if (action && /did not find some options|Option .* not found/.test(msg))
|
|
146
|
+
return out('assertion', `selectOption could not find the requested option${locator ? ' in ' + locator : ''}.`, 'Check the option value or label. Options may load later than the select element.', { action, locator });
|
|
147
|
+
// ── Files and code errors ────────────────────────────────────────────────
|
|
148
|
+
if (/ENOENT: no such file or directory/.test(msg)) {
|
|
149
|
+
const f = pick(msg, /open '([^']+)'|'([^']+)'/) ?? pick(msg, /directory, \w+ '([^']+)'/);
|
|
150
|
+
return out('file', `A file the test needs is missing${f ? ': ' + f : ''}.`, 'Check the path, and that the file is committed or generated before the run (a download, a fixture, a baseline screenshot).');
|
|
151
|
+
}
|
|
152
|
+
m = first.match(/^(TypeError|ReferenceError|SyntaxError|RangeError): (.+)$/);
|
|
153
|
+
if (m) {
|
|
154
|
+
const hint = /is not a function/.test(m[2]) ? 'A method is being called on the wrong object, or a helper was not imported.' : /Cannot read propert|of undefined|of null/.test(m[2]) ? 'Something was undefined at that point: an API response, a fixture, a page object field.' : /is not defined/.test(m[2]) ? 'A variable or import is missing.' : /JSON/.test(m[2]) ? 'The response was not valid JSON. It may be an HTML error page.' : 'This is a bug in the test or a helper, not in the app.';
|
|
155
|
+
return out('script', `${m[1]} in the test code: ${short(m[2])}`, hint);
|
|
156
|
+
}
|
|
157
|
+
if (/^Error: (.+)/.test(first) && !/^Error: (locator|page|frame|browser|expect|apiRequestContext)/.test(first))
|
|
158
|
+
return out('thrown', short(first.replace(/^Error: /, '')), 'The test (or a helper) threw this error on purpose or via a failed check. The stack trace below points at the line.');
|
|
159
|
+
return out('thrown', short(first), 'See the full message and stack trace below.');
|
|
160
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import ReportingLabsReporter from './reporter';
|
|
2
|
-
export type { ReportingLabsOptions, ApiCall } from './types';
|
|
2
|
+
export type { ReportingLabsOptions, ApiCall, ErrorExplainData } from './types';
|
|
3
|
+
export { explainError } from './explain';
|
|
3
4
|
export { ReportingLabsReporter };
|
|
4
5
|
export { meta, log, testData, api, recordApi } from './meta';
|
|
5
6
|
export default ReportingLabsReporter;
|
package/dist/index.js
CHANGED
|
@@ -3,9 +3,11 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
-
exports.recordApi = exports.api = exports.testData = exports.log = exports.meta = exports.ReportingLabsReporter = void 0;
|
|
6
|
+
exports.recordApi = exports.api = exports.testData = exports.log = exports.meta = exports.ReportingLabsReporter = exports.explainError = void 0;
|
|
7
7
|
const reporter_1 = __importDefault(require("./reporter"));
|
|
8
8
|
exports.ReportingLabsReporter = reporter_1.default;
|
|
9
|
+
var explain_1 = require("./explain");
|
|
10
|
+
Object.defineProperty(exports, "explainError", { enumerable: true, get: function () { return explain_1.explainError; } });
|
|
9
11
|
var meta_1 = require("./meta");
|
|
10
12
|
Object.defineProperty(exports, "meta", { enumerable: true, get: function () { return meta_1.meta; } });
|
|
11
13
|
Object.defineProperty(exports, "log", { enumerable: true, get: function () { return meta_1.log; } });
|
package/dist/reporter.d.ts
CHANGED
|
@@ -31,6 +31,8 @@ export default class ReportingLabsReporter implements Reporter {
|
|
|
31
31
|
private metaKeys;
|
|
32
32
|
/** Pull meta values (priority, owner, story, epic...) from annotations and tags. */
|
|
33
33
|
private extractMeta;
|
|
34
|
+
/** A local image file (path relative to the config) is embedded as a data URI so the report stays self-contained. URLs and data URIs pass through. */
|
|
35
|
+
private resolveLogo;
|
|
34
36
|
private rel;
|
|
35
37
|
private serializeError;
|
|
36
38
|
private outcome;
|
package/dist/reporter.js
CHANGED
|
@@ -39,6 +39,7 @@ const os = __importStar(require("os"));
|
|
|
39
39
|
const child_process_1 = require("child_process");
|
|
40
40
|
const template_1 = require("./template");
|
|
41
41
|
const mask_1 = require("./mask");
|
|
42
|
+
const explain_1 = require("./explain");
|
|
42
43
|
const DEFAULT_EMBED_LIMIT = 2 * 1024 * 1024;
|
|
43
44
|
/** Attach steps created by log() and the automatic API capture; the data shows in its own section, so hide the step. */
|
|
44
45
|
function isInternalAttach(s) {
|
|
@@ -167,7 +168,7 @@ class ReportingLabsReporter {
|
|
|
167
168
|
globalOutput: this.globalOutput,
|
|
168
169
|
shard: this.config.shard ? { current: this.config.shard.current, total: this.config.shard.total } : undefined,
|
|
169
170
|
options: {
|
|
170
|
-
logo: this.options.logo,
|
|
171
|
+
logo: this.resolveLogo(this.options.logo),
|
|
171
172
|
accent: this.options.accent,
|
|
172
173
|
theme: this.options.theme ?? 'auto',
|
|
173
174
|
palette: this.options.palette ?? 'lab',
|
|
@@ -329,12 +330,33 @@ class ReportingLabsReporter {
|
|
|
329
330
|
}
|
|
330
331
|
return meta;
|
|
331
332
|
}
|
|
333
|
+
/** A local image file (path relative to the config) is embedded as a data URI so the report stays self-contained. URLs and data URIs pass through. */
|
|
334
|
+
resolveLogo(logo) {
|
|
335
|
+
if (!logo || /^(https?:|data:)/i.test(logo))
|
|
336
|
+
return logo;
|
|
337
|
+
const base = this.config.configFile ? path.dirname(this.config.configFile) : this.config.rootDir;
|
|
338
|
+
const file = path.resolve(base, logo);
|
|
339
|
+
if (!fs.existsSync(file)) {
|
|
340
|
+
console.warn(`reporting-labs: logo not found at ${file}`);
|
|
341
|
+
return undefined;
|
|
342
|
+
}
|
|
343
|
+
const mime = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.svg': 'image/svg+xml', '.gif': 'image/gif', '.webp': 'image/webp', '.ico': 'image/x-icon' };
|
|
344
|
+
const type = mime[path.extname(file).toLowerCase()];
|
|
345
|
+
if (!type) {
|
|
346
|
+
console.warn(`reporting-labs: logo ${logo} is not a png, jpg, svg, gif or webp file`);
|
|
347
|
+
return undefined;
|
|
348
|
+
}
|
|
349
|
+
return `data:${type};base64,${fs.readFileSync(file).toString('base64')}`;
|
|
350
|
+
}
|
|
332
351
|
rel(file) {
|
|
333
352
|
const base = this.config.configFile ? path.dirname(this.config.configFile) : this.config.rootDir;
|
|
334
353
|
return path.relative(base, file).split(path.sep).join('/');
|
|
335
354
|
}
|
|
336
355
|
serializeError(e) {
|
|
337
356
|
const out = { message: stripAnsi(e.message ?? e.value ?? '') };
|
|
357
|
+
const why = (0, explain_1.explainError)(out.message);
|
|
358
|
+
if (why)
|
|
359
|
+
out.explain = why;
|
|
338
360
|
if (e.stack)
|
|
339
361
|
out.stack = stripAnsi(e.stack);
|
|
340
362
|
if (e.snippet)
|
package/dist/template.js
CHANGED
|
@@ -473,6 +473,7 @@ html,body{-webkit-font-smoothing:antialiased}
|
|
|
473
473
|
.hdr h1,.hdr .proj-blk h1{color:#fff;font-size:20px;font-weight:600;letter-spacing:-.01em;line-height:1.2}
|
|
474
474
|
.hdr .proj-blk .sub,.hdr .proj-blk .sub a,.hdr .when{color:rgba(255,255,255,.78)}
|
|
475
475
|
.hdr .logo{width:42px;height:42px;border-radius:11px;flex:none;box-shadow:0 0 0 1px rgba(255,255,255,.35),0 6px 16px -6px rgba(0,0,0,.45)} .hdr .logo svg{width:42px;height:42px;display:block;border-radius:11px}
|
|
476
|
+
.hdr img.logo.brand{width:auto;max-width:180px;height:42px;object-fit:contain;border-radius:8px;background:#fff;padding:4px;box-sizing:border-box}
|
|
476
477
|
.hdr img{height:32px;border-radius:6px}
|
|
477
478
|
.hdr .chip{background:rgba(255,255,255,.12);border:1px solid rgba(255,255,255,.22);color:rgba(255,255,255,.85);border-radius:999px;padding:4px 9px}
|
|
478
479
|
.hdr .chip b{color:#fff}
|
|
@@ -603,7 +604,7 @@ html,body{-webkit-font-smoothing:antialiased}
|
|
|
603
604
|
.attn2 td:first-child{border-radius:6px 0 0 6px} .attn2 td:last-child{border-radius:0 6px 6px 0}
|
|
604
605
|
.attn2 .pr{width:96px;white-space:nowrap;font-size:12.5px;line-height:1.35} .attn2 .pr .dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:7px}
|
|
605
606
|
.attn2 .pr span{font-weight:600;color:var(--ink)} .attn2 .pr small{display:block;color:var(--ink-3);font-size:11.5px;padding-left:15px}
|
|
606
|
-
.attn2 .tt .n{font-weight:500;color:var(--ink);line-height:1.35} .attn2 .tt .s{font-size:12px;color:var(--ink-3);margin-top:2px} .attn2 .tt .s b.new{color:var(--fail);font-weight:600}
|
|
607
|
+
.attn2 .tt .n{font-weight:500;color:var(--ink);line-height:1.35} .attn2 .tt .s{font-size:12px;color:var(--ink-3);margin-top:2px} .attn2 .tt .s b.new{color:var(--fail);font-weight:600} .attn2 .tt .s .rsn{color:var(--ink-2)}
|
|
607
608
|
.attn2 .ow{text-align:right;color:var(--ink-3);font-size:12.5px;white-space:nowrap;width:1%;padding-top:10px}
|
|
608
609
|
.attn-foot{margin-top:auto;padding-top:12px;border-top:1px solid var(--line)} .attn-foot button{font-size:12.5px;font-weight:500;color:var(--accent-2)} .attn-foot button:hover{text-decoration:underline}
|
|
609
610
|
.clu2{list-style:none;margin:0;padding:0}
|
|
@@ -619,6 +620,12 @@ html,body{-webkit-font-smoothing:antialiased}
|
|
|
619
620
|
.note{padding:10px 14px;border-radius:8px;margin-bottom:10px;border:1px solid var(--line)} .note.ok{background:var(--pass-bg);color:var(--ink)} .note.warn{background:var(--flaky-bg);color:var(--flaky-ink)}
|
|
620
621
|
.badge.xfail{background:var(--flaky-bg);color:var(--flaky-ink)}
|
|
621
622
|
.errwrap+.errwrap{margin-top:14px}
|
|
623
|
+
.why{border:1px solid var(--line);border-left:3px solid var(--fail);border-radius:0 var(--radius) var(--radius) 0;padding:10px 14px;margin-bottom:10px;background:var(--surface)}
|
|
624
|
+
.why-c{font-size:12.5px;color:var(--ink);margin-bottom:2px;line-height:1.4} .why-c .why-k{margin-right:2px}
|
|
625
|
+
.why-l{display:flex;align-items:center;gap:8px;margin-bottom:4px} .why-k{font-size:12px;font-weight:600;color:var(--fail)} .why-a{font:11.5px var(--mono);color:var(--ink-3)}
|
|
626
|
+
.why-s{font-size:var(--fs);color:var(--ink);line-height:1.45} .why-h{font-size:12.5px;color:var(--ink-2);margin-top:4px;line-height:1.45}
|
|
627
|
+
.why.assertion,.why.visual{border-left-color:#C77D14} .why.assertion .why-k,.why.visual .why-k{color:#9A6A00}
|
|
628
|
+
.why.script,.why.thrown,.why.file{border-left-color:var(--ink-3)} .why.script .why-k,.why.thrown .why-k,.why.file .why-k{color:var(--ink-2)}
|
|
622
629
|
.errloc{font-family:var(--mono);color:var(--ink-3);margin-bottom:6px} .errloc a{color:var(--accent-2);text-decoration:none} .errloc a:hover{text-decoration:underline}
|
|
623
630
|
.snip{margin:10px 0 0;background:var(--surface-2);border:1px solid var(--line);border-radius:8px;padding:10px 12px;font-family:var(--mono);line-height:1.55;white-space:pre;overflow:auto;color:var(--ink-2)}
|
|
624
631
|
/* ---------- triage extras: history, diff, export, env, heatmap ---------- */
|
|
@@ -725,7 +732,7 @@ app.append(h('div',{class:'band'}, stripe(), header(), nav()),
|
|
|
725
732
|
h('div',{class:'view','data-view':'tests'}, main()),
|
|
726
733
|
h('div',{class:'view','data-view':'failures'}, h('div',{class:'view-pad'}, failuresView())),
|
|
727
734
|
h('div',{class:'view','data-view':'api'}, h('div',{class:'view-pad'}, apiView())),
|
|
728
|
-
h('div',{class:'view','data-view':'timeline'}, h('div',{class:'view-pad'}, timelineView())));
|
|
735
|
+
hasTimelineView()? h('div',{class:'view','data-view':'timeline'}, h('div',{class:'view-pad'}, timelineView())) : null);
|
|
729
736
|
select(state.selected || firstInteresting(), true);
|
|
730
737
|
showView(state.view);
|
|
731
738
|
window.addEventListener('keydown', e=>{
|
|
@@ -740,13 +747,14 @@ window.addEventListener('keydown', e=>{
|
|
|
740
747
|
/* ---------- header ---------- */
|
|
741
748
|
function setHash(){ const p=new URLSearchParams(); if(state.view!=='overview') p.set('view',state.view); if(state.selected&&state.view==='tests') p.set('t',state.selected); const hs=p.toString(); history.replaceState(null,'',hs?'#'+hs:location.pathname+location.search); }
|
|
742
749
|
function showView(v){
|
|
750
|
+
if(!document.querySelector('.view[data-view='+v+']')) v='overview';
|
|
743
751
|
state.view=v; document.querySelectorAll('.view').forEach(el=>el.classList.toggle('on',el.dataset.view===v));
|
|
744
752
|
document.querySelectorAll('.nav button[data-view]').forEach(b=>b.setAttribute('aria-selected',b.dataset.view===v));
|
|
745
753
|
setHash(); try{window.scrollTo(0,0);}catch(e){}
|
|
746
754
|
}
|
|
747
755
|
function nav(){
|
|
748
756
|
const s=data.stats, f=s.failed+s.timedOut+s.interrupted, apiN=data.tests.reduce((a,t)=>a+t.results.reduce((b,r)=>b+r.api.length,0),0);
|
|
749
|
-
const tabs=[['overview','Overview',null],['tests','Tests',s.total],['failures','Failures',f+s.flaky,f>0],['api','API',apiN],['timeline','Timeline',null]].filter(t=>t[0]!=='api'||apiN>0);
|
|
757
|
+
const tabs=[['overview','Overview',null],['tests','Tests',s.total],['failures','Failures',f+s.flaky,f>0],['api','API',apiN],['timeline','Timeline',null]].filter(t=>(t[0]!=='api'||apiN>0)&&(t[0]!=='timeline'||hasTimelineView()));
|
|
750
758
|
return h('nav',{class:'nav'}, h('div',{}, tabs.map(([v,l,n,bad])=>h('button',{'data-view':v,'aria-selected':state.view===v,onclick:()=>showView(v)}, l, n!=null?h('span',{class:'cnt'+(bad?' bad':'')},n):null))));
|
|
751
759
|
}
|
|
752
760
|
function failuresView(){
|
|
@@ -780,8 +788,12 @@ function apiView(){
|
|
|
780
788
|
h('td',{class:'u',title:c.url},c.url), h('td',{}, h('span',{class:'sc '+cls,style:'font:12px var(--mono);font-weight:600;color:'+(cls==='bad'?'var(--fail)':cls==='warn'?'var(--flaky-ink)':'var(--pass)')},st||'—')), h('td',{class:'t'},c.duration!=null?ms(c.duration):'—'), h('td',{class:'t',title:t.title},t.title)); })))));
|
|
781
789
|
}
|
|
782
790
|
function timelineView(){
|
|
783
|
-
|
|
791
|
+
const W=data.options.widgets, cards=[];
|
|
792
|
+
if(W.timeline!==false) cards.push(h('div',{class:'card w12'}, h('h2',{},'Timeline by worker'), timeline()), h('div',{class:'card w6'}, h('h2',{},'Workers'), workers()));
|
|
793
|
+
if(W.durations!==false) cards.push(h('div',{class:W.timeline!==false?'card w6':'card w12'}, h('h2',{},'Duration spread'), histogram()));
|
|
794
|
+
return h('div',{class:'grid'}, cards);
|
|
784
795
|
}
|
|
796
|
+
function hasTimelineView(){ return data.options.widgets.timeline!==false || data.options.widgets.durations!==false; }
|
|
785
797
|
function stripe(){
|
|
786
798
|
const s=data.stats, f=s.failed+s.timedOut+s.interrupted, tot=s.total||1;
|
|
787
799
|
return h('div',{class:'stripe','aria-hidden':'true'}, [['passed',s.passed],['flaky',s.flaky],['failed',f],['skipped',s.skipped]].filter(x=>x[1]).map(([k,n])=>h('i',{style:'width:'+(n/tot*100)+'%;background:'+colorOf(k)})));
|
|
@@ -808,10 +820,11 @@ function header(){
|
|
|
808
820
|
[['lab','Blue'],['ocean','Ocean'],['ember','Ember'],['mono','Mono']].map(([v,l])=>h('option',{value:v,selected:html.getAttribute('data-palette')===v},l)));
|
|
809
821
|
const copy = h('button',{class:'btn',onclick:e=>copyText(summaryMarkdown(),e.currentTarget,'Copied for Slack')}, 'Copy summary');
|
|
810
822
|
return h('header',{class:'hdr'},
|
|
811
|
-
data.options.logo ? h('img',{src:data.options.logo,alt:''}) : h('div',{class:'logo',title:'Generated by reportingLabs','aria-label':'reportingLabs'}, document.getElementById('rl-logo').content.cloneNode(true)),
|
|
823
|
+
data.options.logo ? h('img',{class:'logo brand',src:data.options.logo,alt:''}) : h('div',{class:'logo',title:'Generated by reportingLabs','aria-label':'reportingLabs'}, document.getElementById('rl-logo').content.cloneNode(true)),
|
|
812
824
|
data.options.project ? h('div',{class:'proj-blk'}, h('h1',{}, data.title),
|
|
813
825
|
h('div',{class:'sub'}, [data.options.project.name, data.options.project.version?'v'+data.options.project.version:null, data.options.project.team].filter(Boolean).join(' · '),
|
|
814
|
-
data.options.project.url? [' · ', h('a',{href:data.options.project.url,target:'_blank',rel:'noopener'}, data.options.project.url.replace(/^https?:\/\//,''))] : null)
|
|
826
|
+
data.options.project.url? [' · ', h('a',{href:data.options.project.url,target:'_blank',rel:'noopener'}, data.options.project.url.replace(/^https?:\/\//,''))] : null),
|
|
827
|
+
data.options.project.description? h('div',{class:'sub desc'}, data.options.project.description) : null)
|
|
815
828
|
: h('h1',{}, data.title),
|
|
816
829
|
h('div',{class:'hmeta'}, Object.entries(data.metadata).map(([k,v])=>h('span',{class:'chip'}, k+' ', h('b',{},v)))),
|
|
817
830
|
h('div',{class:'spacer'}),
|
|
@@ -950,7 +963,7 @@ function csvCell(v){ v=v==null?'':String(v); return /[",\n]/.test(v)? '"'+v.repl
|
|
|
950
963
|
function failureRows(){
|
|
951
964
|
const bad=data.tests.filter(t=>isFail(t.outcome)||t.outcome==='flaky').sort((a,b)=>rank(a)-rank(b));
|
|
952
965
|
return bad.map(t=>{ const r=t.results[t.results.length-1], e=r&&r.errors[0], si=sinceInfo(t);
|
|
953
|
-
return { title:t.title, suite:t.path.join(' > '), file:t.file, line:t.line, project:t.project, outcome:label[t.outcome], since:si?(si.kind==='new'?'new':runLabel(si.since)):'', priority:t.meta.priority||'', severity:t.meta.severity||'', owner:t.meta.owner||'', feature:t.meta.feature||'', story:t.meta.story||t.meta.issue||'', epic:t.meta.epic||'', attempts:t.results.length, duration_ms:Math.round(t.duration), error:e?e.message.split('\n')[0]:'', tags:t.tags.join(' ') }; });
|
|
966
|
+
return { title:t.title, suite:t.path.join(' > '), file:t.file, line:t.line, project:t.project, outcome:label[t.outcome], since:si?(si.kind==='new'?'new':runLabel(si.since)):'', priority:t.meta.priority||'', severity:t.meta.severity||'', owner:t.meta.owner||'', feature:t.meta.feature||'', story:t.meta.story||t.meta.issue||'', epic:t.meta.epic||'', attempts:t.results.length, duration_ms:Math.round(t.duration), reason:e&&e.explain?e.explain.label:'', why:e&&e.explain?e.explain.summary:'', error:e?e.message.split('\n')[0]:'', tags:t.tags.join(' ') }; });
|
|
954
967
|
}
|
|
955
968
|
function exportCsv(){ const rows=failureRows(); if(!rows.length) return; const cols=Object.keys(rows[0]); download(fileStem()+'-failures.csv', [cols.join(','), ...rows.map(r=>cols.map(c=>csvCell(r[c])).join(','))].join('\n'), 'text/csv'); }
|
|
956
969
|
function exportJson(){ const rows=failureRows().map(r=>{ const t=data.tests.find(x=>x.title===r.title&&x.project===r.project&&x.file===r.file); const res=t&&t.results[t.results.length-1]; return Object.assign({}, r, {errorFull:res&&res.errors[0]?res.errors[0].message:''}); });
|
|
@@ -969,7 +982,12 @@ function tokenDiff(a,b){
|
|
|
969
982
|
const join=(arr,cls)=>{ const out=[]; for(const [t,d] of arr){ const last=out[out.length-1]; if(last&&last.d===d) last.t+=t; else out.push({t,d}); } return out.map(x=>x.d?h('mark',{class:cls},x.t):x.t); };
|
|
970
983
|
return {a:join(A,'del'), b:join(B,'ins')};
|
|
971
984
|
}
|
|
972
|
-
function errorView(e){ return h('div',{class:'errwrap'}, errorWhere(e), errorBody(e), e.snippet? h('pre',{class:'snip'}, e.snippet) : null); }
|
|
985
|
+
function errorView(e){ return h('div',{class:'errwrap'}, whyView(e.explain), errorWhere(e), errorBody(e), e.snippet? h('pre',{class:'snip'}, e.snippet) : null); }
|
|
986
|
+
function whyView(x){
|
|
987
|
+
if(!x) return null;
|
|
988
|
+
return h('div',{class:'why '+x.kind}, h('div',{class:'why-l'}, h('span',{class:'why-k'},x.label), x.action? h('code',{class:'why-a'},x.action):null),
|
|
989
|
+
h('div',{class:'why-s'},x.summary), x.hint? h('div',{class:'why-h'},x.hint):null);
|
|
990
|
+
}
|
|
973
991
|
function errorWhere(e){
|
|
974
992
|
if(!e.location) return null;
|
|
975
993
|
const l=e.location, txt=l.file+':'+l.line+(l.column?':'+l.column:'');
|
|
@@ -1041,7 +1059,7 @@ function hero(){
|
|
|
1041
1059
|
else delta=ok? 'Nothing to worry about.' : failed+' failed'+(s.flaky?', '+s.flaky+' flaky':'')+(s.skipped?', '+s.skipped+' skipped':'');
|
|
1042
1060
|
return h('button',{class:'pill kpi hero','aria-pressed':state.status==='all','data-k':'all',title:'Show all tests',onclick:()=>{state.status='all';refresh();showView('tests');}},
|
|
1043
1061
|
spark(hseries(e=>{ const rn=e.total-e.skipped; return rn?Math.round(e.passed/rn*100):0; }),'#fff'),
|
|
1044
|
-
ring,
|
|
1062
|
+
data.options.widgets.outcome!==false? ring : null,
|
|
1045
1063
|
h('div',{class:'body'},
|
|
1046
1064
|
h('div',{class:'l'}, ok? 'All passed' : 'Pass rate'),
|
|
1047
1065
|
h('div',{class:'v'}, s.passed, h('small',{}, 'of '+ran+' ran')),
|
|
@@ -1067,6 +1085,7 @@ function attention(limit){
|
|
|
1067
1085
|
const sub=(t,projects)=>{ const parts=[t.file.split('/').pop()];
|
|
1068
1086
|
if(data.projects.length>1) parts.push(projects.length>1? projects.length+' projects' : projects[0]);
|
|
1069
1087
|
const si=sinceInfo(t); if(si) parts.push(si.kind==='new'? h('b',{class:'new'},'new this run') : 'failing since '+runLabel(si.since));
|
|
1088
|
+
const le=t.results[t.results.length-1]&&t.results[t.results.length-1].errors[0]; if(le&&le.explain) parts.push(h('span',{class:'rsn',title:le.explain.summary},le.explain.label));
|
|
1070
1089
|
else if(t.outcome==='flaky') parts.push('passed on retry '+t.results.length);
|
|
1071
1090
|
const out=[]; parts.forEach((x,k)=>{ if(k) out.push(' · '); out.push(x); }); return out; };
|
|
1072
1091
|
const tbl=h('table',{class:'attn2'}, h('tbody',{}, rows.map(({t,projects})=>h('tr',{tabindex:'0',onclick:()=>select(t.id),onkeydown:e=>{ if(e.key==='Enter') select(t.id); }},
|
|
@@ -1121,7 +1140,7 @@ function errorSignature(msg){
|
|
|
1121
1140
|
}
|
|
1122
1141
|
function failureClusters(){
|
|
1123
1142
|
const m=new Map();
|
|
1124
|
-
for(const t of data.tests){ if(!isFail(t.outcome)) continue; const r=t.results[t.results.length-1]; const e=r&&r.errors[0]; const sig=errorSignature(e?e.message:'(no error message)'); const c=m.get(sig)||{sig,sample:e?e.message.split('\n').slice(0,2).join('\n'):'(no error message)',tests:[]}; c.tests.push(t); m.set(sig,c); }
|
|
1143
|
+
for(const t of data.tests){ if(!isFail(t.outcome)) continue; const r=t.results[t.results.length-1]; const e=r&&r.errors[0]; const x=e&&e.explain; const sig=x&&!/^(thrown|script)$/.test(x.kind)? x.kind+'|'+(x.locator||x.url||'')+'|'+(x.matcher||x.action||'') : errorSignature(e?e.message:'(no error message)'); const c=m.get(sig)||{sig,sample:e?e.message.split('\n').slice(0,2).join('\n'):'(no error message)',why:e&&e.explain?e.explain:null,tests:[]}; c.tests.push(t); m.set(sig,c); }
|
|
1125
1144
|
return [...m.values()].sort((a,b)=>b.tests.length-a.tests.length);
|
|
1126
1145
|
}
|
|
1127
1146
|
function clustersView(cl){
|
|
@@ -1131,7 +1150,7 @@ function clustersView(cl){
|
|
|
1131
1150
|
const who=[]; names.forEach(([title,ts],k)=>{ if(k) who.push(h('span',{class:'sep'},'·')); who.push(h('button',{onclick:()=>select(ts[0].id),title:ts.map(t=>t.project).join(', ')}, title, data.projects.length>1&&ts.length>1? h('small',{},' ×'+ts.length) : null)); });
|
|
1132
1151
|
if(extra>0) who.push(h('span',{class:'sep'},'·'), h('span',{class:'ex'},'+'+extra+' more'));
|
|
1133
1152
|
return h('li',{}, h('span',{class:'n'}, c.tests.length, h('small',{},c.tests.length===1?'test':'tests')),
|
|
1134
|
-
h('div',{class:'b'}, h('div',{class:'msg',title:c.sample}, c.sample.split('\n')[0]), h('div',{class:'who'}, who)));
|
|
1153
|
+
h('div',{class:'b'}, c.why? h('div',{class:'why-c'}, h('span',{class:'why-k'},c.why.label), ' ', c.why.summary) : null, h('div',{class:'msg',title:c.sample}, c.sample.split('\n')[0]), h('div',{class:'who'}, who)));
|
|
1135
1154
|
}));
|
|
1136
1155
|
}
|
|
1137
1156
|
function trend(){
|
package/dist/types.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export interface ReportingLabsOptions {
|
|
2
2
|
/** Report title shown in the header. Default: "Test report" */
|
|
3
3
|
title?: string;
|
|
4
|
-
/**
|
|
4
|
+
/** Your logo: a file path relative to the config (e.g. 'logo.png', embedded into the report), an https URL, or a data URI. */
|
|
5
5
|
logo?: string;
|
|
6
6
|
/** Override the palette accent with your brand color, e.g. '#7C3AED'. Default: the palette's own accent (royal blue for `lab`). */
|
|
7
7
|
accent?: string;
|
|
@@ -148,6 +148,17 @@ export interface EnvRow {
|
|
|
148
148
|
href?: string;
|
|
149
149
|
}
|
|
150
150
|
export type Status = 'passed' | 'failed' | 'skipped' | 'flaky' | 'timedOut' | 'interrupted';
|
|
151
|
+
export interface ErrorExplainData {
|
|
152
|
+
kind: string;
|
|
153
|
+
label: string;
|
|
154
|
+
summary: string;
|
|
155
|
+
hint?: string;
|
|
156
|
+
locator?: string;
|
|
157
|
+
action?: string;
|
|
158
|
+
matcher?: string;
|
|
159
|
+
timeoutMs?: number;
|
|
160
|
+
url?: string;
|
|
161
|
+
}
|
|
151
162
|
export interface ErrorData {
|
|
152
163
|
message: string;
|
|
153
164
|
stack?: string;
|
|
@@ -156,7 +167,8 @@ export interface ErrorData {
|
|
|
156
167
|
file: string;
|
|
157
168
|
line: number;
|
|
158
169
|
column: number;
|
|
159
|
-
};
|
|
170
|
+
}; /** Plain-language reading of the message, rule based. The message itself is always kept. */
|
|
171
|
+
explain?: ErrorExplainData;
|
|
160
172
|
}
|
|
161
173
|
export interface StepData {
|
|
162
174
|
title: string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "reporting-labs",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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",
|