reporting-labs 0.2.1 → 0.4.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 +2 -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 +91 -9
- package/dist/types.d.ts +13 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -160,6 +160,7 @@ Run it two or three times to see the history features (new vs known failures, fl
|
|
|
160
160
|
|
|
161
161
|
### Failures: everything you need to triage
|
|
162
162
|
|
|
163
|
+
- **Bug report** button on every failed test: a ready-to-paste ticket with priority, owner, story, environment, test data, steps to reproduce (taken from the test's own steps, the failing one marked), expected vs actual, the error, API calls and attachment names. Pick Markdown, Jira or plain text, edit if you like, copy or download.
|
|
163
164
|
- **By owner**: who to ping, with failed and flaky counts. Click an owner to filter.
|
|
164
165
|
- **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
166
|
- **Copy summary**: a Slack or Teams message with the top failures, owners and ticket keys.
|
|
@@ -169,6 +170,7 @@ Run it two or three times to see the history features (new vs known failures, fl
|
|
|
169
170
|
|
|
170
171
|
### Test detail: the error, the steps, the proof
|
|
171
172
|
|
|
173
|
+
- **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
174
|
- **Expected vs received** side by side with the difference highlighted.
|
|
173
175
|
- Error **location** (opens in VS Code), Playwright's **code snippet**, full message and stack.
|
|
174
176
|
- **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
|
@@ -306,6 +306,13 @@ li.collapsed>ul{display:none}
|
|
|
306
306
|
.att .file a{font:12px var(--mono)}
|
|
307
307
|
.txt{background:var(--surface-2);padding:10px 12px;border-radius:var(--radius);font:12px/1.5 var(--mono);white-space:pre-wrap;max-height:260px;overflow:auto}
|
|
308
308
|
.lb{position:fixed;inset:0;background:rgba(0,0,0,.85);display:grid;place-items:center;z-index:10;cursor:zoom-out}
|
|
309
|
+
.bugdlg{position:fixed;inset:0;background:rgba(10,20,40,.55);display:grid;place-items:center;z-index:11;padding:20px}
|
|
310
|
+
.bug-box{background:var(--surface);color:var(--ink);border-radius:12px;width:min(860px,100%);max-height:92vh;display:flex;flex-direction:column;box-shadow:0 30px 80px -20px rgba(0,0,0,.5);border:1px solid var(--line)}
|
|
311
|
+
.bug-head{display:flex;align-items:center;gap:12px;padding:14px 18px;border-bottom:1px solid var(--line)} .bug-head h3{margin:0;font-size:16px} .bug-head .hint{flex:1;color:var(--ink-3);font-size:12px}
|
|
312
|
+
.bug-tools{display:flex;align-items:center;gap:8px;padding:10px 18px;border-bottom:1px solid var(--line)} .bug-tools .grow{flex:1}
|
|
313
|
+
.bug-ta{flex:1;min-height:360px;margin:0;padding:14px 18px;border:0;resize:none;background:var(--surface-2);color:var(--ink);font:12.5px/1.55 var(--mono);outline:none;border-radius:0 0 12px 12px;white-space:pre}
|
|
314
|
+
.btn.primary{background:var(--accent);border-color:var(--accent);color:#fff} .btn.primary.done{background:var(--pass);border-color:var(--pass)}
|
|
315
|
+
.detail .actions .btn{cursor:pointer}
|
|
309
316
|
.lb img{max-width:95vw;max-height:95vh;border-radius:4px}
|
|
310
317
|
.kv{display:grid;grid-template-columns:auto 1fr;gap:4px 14px;font-size:13px}
|
|
311
318
|
.kv dt{color:var(--ink-3)} .kv dd{margin:0}
|
|
@@ -408,8 +415,8 @@ li.collapsed>ul{display:none}
|
|
|
408
415
|
.wk .n{font:11px var(--mono);color:var(--ink-3);text-align:right}
|
|
409
416
|
.wk-sum{font-size:13px;color:var(--ink-2);margin-bottom:10px}
|
|
410
417
|
/* list: group toggle + folder tree */
|
|
411
|
-
.tools .seg{display:inline-flex;border:1px solid var(--line);border-radius:var(--radius);overflow:hidden}
|
|
412
|
-
.tools .seg button{padding:6px 10px;font-size:12px;color:var(--ink-2)} .tools .seg button[aria-pressed=true]{background:var(--surface-2);color:var(--ink)}
|
|
418
|
+
.tools .seg,.bug-tools .seg{display:inline-flex;border:1px solid var(--line);border-radius:var(--radius);overflow:hidden}
|
|
419
|
+
.tools .seg button,.bug-tools .seg button{padding:6px 10px;font-size:12px;color:var(--ink-2);cursor:pointer} .tools .seg button[aria-pressed=true],.bug-tools .seg button[aria-pressed=true]{background:var(--surface-2);color:var(--ink)}
|
|
413
420
|
.folder{display:flex;align-items:center;gap:6px;padding:8px 12px 4px;font:12px var(--mono);color:var(--ink-2);width:100%;text-align:left}
|
|
414
421
|
.folder .cnt{margin-left:auto;display:flex;gap:6px} .folder .cnt b{color:var(--fail)} .folder .cnt span{color:var(--ink-3)}
|
|
415
422
|
.folder .tw{font-size:9px;color:var(--ink-3)}
|
|
@@ -566,7 +573,7 @@ html,body{-webkit-font-smoothing:antialiased}
|
|
|
566
573
|
.tools{padding:12px;gap:8px;background:var(--surface)}
|
|
567
574
|
.tools input,.tools select{border-radius:8px;background:var(--bg);border-color:var(--line)}
|
|
568
575
|
.tools input:focus,.tools select:focus{border-color:var(--accent);outline:0;box-shadow:0 0 0 3px var(--accent-tint)}
|
|
569
|
-
.tools .seg{border-radius:8px} .tools .seg button[aria-pressed=true]{background:var(--accent-tint);color:var(--accent-2);font-weight:500}
|
|
576
|
+
.tools .seg,.bug-tools .seg{border-radius:8px} .tools .seg button[aria-pressed=true],.bug-tools .seg button[aria-pressed=true]{background:var(--accent-tint);color:var(--accent-2);font-weight:500}
|
|
570
577
|
.item{border-left-width:3px;padding:9px 12px}
|
|
571
578
|
.item[aria-current=true]{background:var(--accent-tint);border-left-color:var(--accent)}
|
|
572
579
|
.item .tt .n{font-weight:500}
|
|
@@ -604,7 +611,7 @@ html,body{-webkit-font-smoothing:antialiased}
|
|
|
604
611
|
.attn2 td:first-child{border-radius:6px 0 0 6px} .attn2 td:last-child{border-radius:0 6px 6px 0}
|
|
605
612
|
.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
613
|
.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}
|
|
614
|
+
.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
615
|
.attn2 .ow{text-align:right;color:var(--ink-3);font-size:12.5px;white-space:nowrap;width:1%;padding-top:10px}
|
|
609
616
|
.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
617
|
.clu2{list-style:none;margin:0;padding:0}
|
|
@@ -620,6 +627,12 @@ html,body{-webkit-font-smoothing:antialiased}
|
|
|
620
627
|
.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
628
|
.badge.xfail{background:var(--flaky-bg);color:var(--flaky-ink)}
|
|
622
629
|
.errwrap+.errwrap{margin-top:14px}
|
|
630
|
+
.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)}
|
|
631
|
+
.why-c{font-size:12.5px;color:var(--ink);margin-bottom:2px;line-height:1.4} .why-c .why-k{margin-right:2px}
|
|
632
|
+
.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)}
|
|
633
|
+
.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}
|
|
634
|
+
.why.assertion,.why.visual{border-left-color:#C77D14} .why.assertion .why-k,.why.visual .why-k{color:#9A6A00}
|
|
635
|
+
.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
636
|
.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
637
|
.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
638
|
/* ---------- triage extras: history, diff, export, env, heatmap ---------- */
|
|
@@ -957,7 +970,7 @@ function csvCell(v){ v=v==null?'':String(v); return /[",\n]/.test(v)? '"'+v.repl
|
|
|
957
970
|
function failureRows(){
|
|
958
971
|
const bad=data.tests.filter(t=>isFail(t.outcome)||t.outcome==='flaky').sort((a,b)=>rank(a)-rank(b));
|
|
959
972
|
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(' ') }; });
|
|
973
|
+
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
974
|
}
|
|
962
975
|
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
976
|
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 +989,12 @@ function tokenDiff(a,b){
|
|
|
976
989
|
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
990
|
return {a:join(A,'del'), b:join(B,'ins')};
|
|
978
991
|
}
|
|
979
|
-
function errorView(e){ return h('div',{class:'errwrap'}, errorWhere(e), errorBody(e), e.snippet? h('pre',{class:'snip'}, e.snippet) : null); }
|
|
992
|
+
function errorView(e){ return h('div',{class:'errwrap'}, whyView(e.explain), errorWhere(e), errorBody(e), e.snippet? h('pre',{class:'snip'}, e.snippet) : null); }
|
|
993
|
+
function whyView(x){
|
|
994
|
+
if(!x) return null;
|
|
995
|
+
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),
|
|
996
|
+
h('div',{class:'why-s'},x.summary), x.hint? h('div',{class:'why-h'},x.hint):null);
|
|
997
|
+
}
|
|
980
998
|
function errorWhere(e){
|
|
981
999
|
if(!e.location) return null;
|
|
982
1000
|
const l=e.location, txt=l.file+':'+l.line+(l.column?':'+l.column:'');
|
|
@@ -1074,6 +1092,7 @@ function attention(limit){
|
|
|
1074
1092
|
const sub=(t,projects)=>{ const parts=[t.file.split('/').pop()];
|
|
1075
1093
|
if(data.projects.length>1) parts.push(projects.length>1? projects.length+' projects' : projects[0]);
|
|
1076
1094
|
const si=sinceInfo(t); if(si) parts.push(si.kind==='new'? h('b',{class:'new'},'new this run') : 'failing since '+runLabel(si.since));
|
|
1095
|
+
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
1096
|
else if(t.outcome==='flaky') parts.push('passed on retry '+t.results.length);
|
|
1078
1097
|
const out=[]; parts.forEach((x,k)=>{ if(k) out.push(' · '); out.push(x); }); return out; };
|
|
1079
1098
|
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 +1147,7 @@ function errorSignature(msg){
|
|
|
1128
1147
|
}
|
|
1129
1148
|
function failureClusters(){
|
|
1130
1149
|
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); }
|
|
1150
|
+
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
1151
|
return [...m.values()].sort((a,b)=>b.tests.length-a.tests.length);
|
|
1133
1152
|
}
|
|
1134
1153
|
function clustersView(cl){
|
|
@@ -1138,7 +1157,7 @@ function clustersView(cl){
|
|
|
1138
1157
|
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
1158
|
if(extra>0) who.push(h('span',{class:'sep'},'·'), h('span',{class:'ex'},'+'+extra+' more'));
|
|
1140
1159
|
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)));
|
|
1160
|
+
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
1161
|
}));
|
|
1143
1162
|
}
|
|
1144
1163
|
function trend(){
|
|
@@ -1313,7 +1332,7 @@ function renderDetail(t){
|
|
|
1313
1332
|
d.append(h('div',{class:'crumb'}, [t.file, ...t.path.map(p=>data.bdd&&!/^Feature:/i.test(p)?'Feature: '+p:p)].map(p=>h('span',{},p))),
|
|
1314
1333
|
h('div',{class:'titlebar'}, h('h3',{},t.title), h('div',{class:'actions'},
|
|
1315
1334
|
h('button',{class:'btn',onclick:e=>copyText(location.href.split('#')[0]+'#t='+t.id,e.currentTarget,'Link copied')},'Copy link'),
|
|
1316
|
-
(()=>{ const r=t.results[t.results.length-1]; const e=r&&r.errors[0]; return e? h('button',{class:'btn',onclick:ev=>copyText(t.title+'\n'+t.file+':'+t.line+'\n\n'+e.message,ev.currentTarget,'Error copied')},'Copy error') : null; })(),
|
|
1335
|
+
(()=>{ const r=t.results[t.results.length-1]; const e=r&&r.errors[0]; return e? [h('button',{class:'btn',onclick:ev=>copyText(t.title+'\n'+t.file+':'+t.line+'\n\n'+e.message,ev.currentTarget,'Error copied')},'Copy error'), h('button',{class:'btn primary',onclick:()=>openBugReport(t)},'Bug report')] : null; })(),
|
|
1317
1336
|
editorLink(t))),
|
|
1318
1337
|
h('div',{class:'badges'}, h('span',{class:'badge '+t.outcome},label[t.outcome]), t.expectedFailure? h('span',{class:'badge xfail',title:'Marked test.fail(): failing is the expected result'},'Expected failure') : null, sinceBadge(t), dots(t,10), data.bdd?h('span',{class:'badge scenario'},'Scenario'):null, t.tags.map(g=>h('span',{class:'badge tag'},g)), data.projects.length>1?h('span',{class:'badge tag'},t.project):null, h('span',{class:'loc'}, t.file+':'+t.line+' · '+ms(t.duration)+(t.outcome==='timedOut'&&t.timeout?' · timeout '+ms(t.timeout):'')+(t.retries?' · retries '+t.retries:''))));
|
|
1319
1338
|
const metaKeys=Object.keys(t.meta);
|
|
@@ -1417,6 +1436,69 @@ function compare(set){
|
|
|
1417
1436
|
for(const [v,l] of [['slider','Slider'],['side','Side by side'],['diff','Diff']]) tabs.append(h('button',{class:'tab','data-v':v,onclick:()=>{cur=v;render();}},l));
|
|
1418
1437
|
wrap.append(tabs,stage); render(); return wrap;
|
|
1419
1438
|
}
|
|
1439
|
+
/* ---- bug report: a ready-to-paste ticket built from the failure ---- */
|
|
1440
|
+
function bugModel(t){
|
|
1441
|
+
const r=t.results[t.results.length-1], e=r&&r.errors[0], x=e&&e.explain, msg=e?e.message:'';
|
|
1442
|
+
const ex=msg.match(/^(Expected[^:\n]{0,40}):[ \t]*(.+)$/m), rc=msg.match(/^(Received[^:\n]{0,40}):[ \t]*(.+)$/m);
|
|
1443
|
+
const si=sinceInfo(t);
|
|
1444
|
+
const steps=[]; const walk=(list,depth)=>{ for(const st of list){ if(st.category==='hook'||/^Attach "/.test(st.title)||/^Worker Cleanup/.test(st.title)) continue; const user=st.category==='test.step'; if(user||depth===0) steps.push({title:st.title.replace(/^(Given|When|Then|And|But)\s+/,m=>m), failed:!!st.error, user}); if(user&&st.steps.length&&!st.steps.some(c=>c.category==='test.step')) continue; if(st.steps.length) walk(st.steps,depth+1); } };
|
|
1445
|
+
if(r) walk(r.steps,0);
|
|
1446
|
+
const userSteps=steps.filter(s=>s.user); const repro=(userSteps.length?userSteps:steps).filter(s=>!/^Expect "/.test(s.title)||s.failed);
|
|
1447
|
+
const env=[]; if(data.options.project&&data.options.project.name) env.push(['Application', data.options.project.name+(data.options.project.version?' v'+data.options.project.version:'')]);
|
|
1448
|
+
for(const [k,v] of Object.entries(data.metadata||{})) env.push([k,v]);
|
|
1449
|
+
if(data.projects.length>1||t.project) env.push(['Playwright project', t.project]);
|
|
1450
|
+
for(const row of (data.env||[])) if(!/^(Workers|Shard)$/.test(row.k)) env.push([row.k, row.href&&row.href!==row.v? row.v+' ('+row.href+')' : row.v]);
|
|
1451
|
+
const dataBlocks=(r?r.data:[]).map(b=>{ if(b.kind==='kv') return {name:b.name, lines:b.kv.map(([k,v])=>k+': '+v)}; if(b.kind==='table') return {name:b.name, lines:b.rows.slice(0,5).map(row=>row.map((c,i)=>(b.columns[i]||'')+'='+c).join(', ')).concat(b.rows.length>5?['… '+(b.rows.length-5)+' more rows']:[])}; return {name:b.name, lines:[String(b.text||'').slice(0,300)]}; });
|
|
1452
|
+
const api=(r?r.api:[]).map(c=>c.method.toUpperCase()+' '+c.url+' → '+(c.status||'no response')+(c.duration!=null?' ('+ms(c.duration)+')':''));
|
|
1453
|
+
const logs=(r?r.logs:[]).slice(-8).map(l=>l.msg);
|
|
1454
|
+
const att=(r?r.attachments:[]).filter(a=>a.contentType.startsWith('image/')||a.contentType.startsWith('video/')||/zip/.test(a.contentType)).map(a=>{ const kind=a.contentType.startsWith('video/')?'video':/zip/.test(a.contentType)?'trace':'screenshot'; return a.name.toLowerCase()===kind? kind : a.name+' ('+kind+')'; });
|
|
1455
|
+
const expected= ex? ex[2].trim() : (x&&x.kind==='not-found'? 'The element is present and the step completes.' : x&&x.kind==='not-visible'? 'The element is visible.' : x&&/^(network|navigation)$/.test(x.kind)? 'The page loads.' : x&&x.kind==='api'? 'The API responds successfully.' : 'The step completes and the test passes.');
|
|
1456
|
+
const actual= rc? rc[2].trim() : (x? x.summary : msg.split('\n')[0]);
|
|
1457
|
+
return { t, r, e, x, si, repro, env, dataBlocks, api, logs, att, expected, actual,
|
|
1458
|
+
title: t.title+(x? ' – '+x.label.toLowerCase() : ''),
|
|
1459
|
+
where: t.file+':'+t.line+(data.projects.length>1?' ['+t.project+']':''),
|
|
1460
|
+
history: si? (si.kind==='new'? 'New failure in this run' : 'Failing since '+runLabel(si.since)) : (t.outcome==='flaky'? 'Flaky: passed on retry' : ''),
|
|
1461
|
+
attempts: t.results.length, when: new Date(data.startTime).toLocaleString() };
|
|
1462
|
+
}
|
|
1463
|
+
function bugReport(t, fmt){
|
|
1464
|
+
const m=bugModel(t), L=[];
|
|
1465
|
+
const H=(txt)=> fmt==='jira'? 'h3. '+txt : fmt==='md'? '### '+txt : txt.toUpperCase();
|
|
1466
|
+
const B=(txt)=> fmt==='text'? txt : '*'+txt+'*';
|
|
1467
|
+
const FENCE=String.fromCharCode(96,96,96);
|
|
1468
|
+
const code=(txt)=> fmt==='jira'? ['{code}',txt,'{code}'] : fmt==='md'? [FENCE,txt,FENCE] : [txt];
|
|
1469
|
+
const li=(i,txt)=> (fmt==='jira'? '# ' : (i+1)+'. ')+txt;
|
|
1470
|
+
const bullet=(txt)=> (fmt==='jira'? '* ' : '- ')+txt;
|
|
1471
|
+
const kv=(k,v)=> fmt==='text'? k+': '+v : (fmt==='jira'? '*'+k+':* ' : '**'+k+':** ')+v;
|
|
1472
|
+
L.push(fmt==='jira'? 'h2. '+m.title : fmt==='md'? '## '+m.title : m.title, '');
|
|
1473
|
+
const pr=[m.t.meta.priority, m.t.meta.severity].filter(Boolean).join(' / ');
|
|
1474
|
+
if(pr) L.push(kv('Priority', pr)); if(m.t.meta.owner) L.push(kv('Owner', m.t.meta.owner)); if(m.t.meta.feature) L.push(kv('Feature', m.t.meta.feature));
|
|
1475
|
+
for(const k of ['story','epic','issue']) if(m.t.meta[k]) L.push(kv(k[0].toUpperCase()+k.slice(1), m.t.meta[k]));
|
|
1476
|
+
L.push(kv('Test', m.where), kv('Run', m.when+(m.attempts>1?' · failed '+m.attempts+' attempts':'')+(m.history?' · '+m.history:'')), '');
|
|
1477
|
+
if(m.x){ L.push(H('Summary'), m.x.summary, ''); }
|
|
1478
|
+
if(m.env.length){ L.push(H('Environment')); for(const [k,v] of m.env) L.push(bullet(k+': '+v)); L.push(''); }
|
|
1479
|
+
if(m.dataBlocks.length){ L.push(H('Test data')); for(const b of m.dataBlocks){ L.push(B(b.name)); for(const ln of b.lines) L.push(bullet(ln)); } L.push(''); }
|
|
1480
|
+
L.push(H('Steps to reproduce'));
|
|
1481
|
+
if(m.repro.length) m.repro.forEach((s,i)=>L.push(li(i, s.title+(s.failed?' ← fails here':'')))); else L.push(li(0,'Run the test: '+m.where));
|
|
1482
|
+
L.push('', H('Expected result'), m.expected, '', H('Actual result'), m.actual, '');
|
|
1483
|
+
if(m.e){ L.push(H('Error')); L.push(...code(m.e.message.split('\n').slice(0,12).join('\n').trim())); L.push(''); }
|
|
1484
|
+
if(m.api.length){ L.push(H('API calls')); for(const a of m.api) L.push(bullet(a)); L.push(''); }
|
|
1485
|
+
if(m.logs.length){ L.push(H('Test log')); L.push(...code(m.logs.join('\n'))); L.push(''); }
|
|
1486
|
+
if(m.att.length){ L.push(H('Attachments')); L.push('In the report next to this test: '+m.att.join(', ')); L.push(''); }
|
|
1487
|
+
L.push(fmt==='text'? 'Generated by reportingLabs' : '_Generated by reportingLabs_');
|
|
1488
|
+
return L.join('\n').replace(/\n{3,}/g,'\n\n');
|
|
1489
|
+
}
|
|
1490
|
+
function openBugReport(t){
|
|
1491
|
+
let fmt=localStorage.getItem('rl-bug-fmt')||'md';
|
|
1492
|
+
const ta=h('textarea',{class:'bug-ta',spellcheck:'false'});
|
|
1493
|
+
const render=()=>{ ta.value=bugReport(t,fmt); ta.scrollTop=0; seg.querySelectorAll('button').forEach(b=>b.setAttribute('aria-pressed',b.dataset.f===fmt)); };
|
|
1494
|
+
const seg=h('div',{class:'seg'}, [['md','Markdown'],['jira','Jira'],['text','Plain text']].map(([f,l])=>h('button',{'data-f':f,onclick:()=>{fmt=f; try{localStorage.setItem('rl-bug-fmt',f);}catch(e){} render();}},l)));
|
|
1495
|
+
const dlg=h('div',{class:'bugdlg',onclick:e=>{ if(e.target===dlg) dlg.remove(); }},
|
|
1496
|
+
h('div',{class:'bug-box',role:'dialog','aria-label':'Bug report'},
|
|
1497
|
+
h('div',{class:'bug-head'}, h('h3',{},'Bug report'), h('span',{class:'hint'},'Edit the text if you like, then copy it into Jira, GitHub, Azure DevOps or an email.'), h('button',{class:'btn',onclick:()=>dlg.remove()},'Close')),
|
|
1498
|
+
h('div',{class:'bug-tools'}, seg, h('div',{class:'grow'}), h('button',{class:'btn',onclick:()=>download((fileStem()+'-'+t.title.replace(/[^a-z0-9]+/gi,'-').toLowerCase()).slice(0,80)+(fmt==='md'?'.md':'.txt'), ta.value, 'text/plain')},'Download'), h('button',{class:'btn primary',onclick:e=>copyText(ta.value,e.currentTarget,'Copied')},'Copy')),
|
|
1499
|
+
ta));
|
|
1500
|
+
render(); document.body.append(dlg); ta.scrollTop=0; try{ ta.focus({preventScroll:true}); ta.setSelectionRange(0,0); }catch(e){}
|
|
1501
|
+
}
|
|
1420
1502
|
function lightbox(src){ const lb=h('div',{class:'lb',onclick:()=>lb.remove()}, h('img',{src})); document.body.append(lb); }
|
|
1421
1503
|
function escape(s){ return s.replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c])); }
|
|
1422
1504
|
})();
|
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.4.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",
|