reporting-labs 0.2.1 → 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 +1 -0
- 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.js +4 -0
- package/dist/template.js +17 -5
- package/dist/types.d.ts +13 -1
- 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.
|
|
@@ -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.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) {
|
|
@@ -353,6 +354,9 @@ class ReportingLabsReporter {
|
|
|
353
354
|
}
|
|
354
355
|
serializeError(e) {
|
|
355
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;
|
|
356
360
|
if (e.stack)
|
|
357
361
|
out.stack = stripAnsi(e.stack);
|
|
358
362
|
if (e.snippet)
|
package/dist/template.js
CHANGED
|
@@ -604,7 +604,7 @@ html,body{-webkit-font-smoothing:antialiased}
|
|
|
604
604
|
.attn2 td:first-child{border-radius:6px 0 0 6px} .attn2 td:last-child{border-radius:0 6px 6px 0}
|
|
605
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}
|
|
606
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}
|
|
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}
|
|
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)}
|
|
608
608
|
.attn2 .ow{text-align:right;color:var(--ink-3);font-size:12.5px;white-space:nowrap;width:1%;padding-top:10px}
|
|
609
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}
|
|
610
610
|
.clu2{list-style:none;margin:0;padding:0}
|
|
@@ -620,6 +620,12 @@ html,body{-webkit-font-smoothing:antialiased}
|
|
|
620
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)}
|
|
621
621
|
.badge.xfail{background:var(--flaky-bg);color:var(--flaky-ink)}
|
|
622
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)}
|
|
623
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}
|
|
624
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)}
|
|
625
631
|
/* ---------- triage extras: history, diff, export, env, heatmap ---------- */
|
|
@@ -957,7 +963,7 @@ function csvCell(v){ v=v==null?'':String(v); return /[",\n]/.test(v)? '"'+v.repl
|
|
|
957
963
|
function failureRows(){
|
|
958
964
|
const bad=data.tests.filter(t=>isFail(t.outcome)||t.outcome==='flaky').sort((a,b)=>rank(a)-rank(b));
|
|
959
965
|
return bad.map(t=>{ const r=t.results[t.results.length-1], e=r&&r.errors[0], si=sinceInfo(t);
|
|
960
|
-
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(' ') }; });
|
|
961
967
|
}
|
|
962
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'); }
|
|
963
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:''}); });
|
|
@@ -976,7 +982,12 @@ function tokenDiff(a,b){
|
|
|
976
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); };
|
|
977
983
|
return {a:join(A,'del'), b:join(B,'ins')};
|
|
978
984
|
}
|
|
979
|
-
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
|
+
}
|
|
980
991
|
function errorWhere(e){
|
|
981
992
|
if(!e.location) return null;
|
|
982
993
|
const l=e.location, txt=l.file+':'+l.line+(l.column?':'+l.column:'');
|
|
@@ -1074,6 +1085,7 @@ function attention(limit){
|
|
|
1074
1085
|
const sub=(t,projects)=>{ const parts=[t.file.split('/').pop()];
|
|
1075
1086
|
if(data.projects.length>1) parts.push(projects.length>1? projects.length+' projects' : projects[0]);
|
|
1076
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));
|
|
1077
1089
|
else if(t.outcome==='flaky') parts.push('passed on retry '+t.results.length);
|
|
1078
1090
|
const out=[]; parts.forEach((x,k)=>{ if(k) out.push(' · '); out.push(x); }); return out; };
|
|
1079
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); }},
|
|
@@ -1128,7 +1140,7 @@ function errorSignature(msg){
|
|
|
1128
1140
|
}
|
|
1129
1141
|
function failureClusters(){
|
|
1130
1142
|
const m=new Map();
|
|
1131
|
-
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); }
|
|
1132
1144
|
return [...m.values()].sort((a,b)=>b.tests.length-a.tests.length);
|
|
1133
1145
|
}
|
|
1134
1146
|
function clustersView(cl){
|
|
@@ -1138,7 +1150,7 @@ function clustersView(cl){
|
|
|
1138
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)); });
|
|
1139
1151
|
if(extra>0) who.push(h('span',{class:'sep'},'·'), h('span',{class:'ex'},'+'+extra+' more'));
|
|
1140
1152
|
return h('li',{}, h('span',{class:'n'}, c.tests.length, h('small',{},c.tests.length===1?'test':'tests')),
|
|
1141
|
-
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)));
|
|
1142
1154
|
}));
|
|
1143
1155
|
}
|
|
1144
1156
|
function trend(){
|
package/dist/types.d.ts
CHANGED
|
@@ -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",
|