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 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;
@@ -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.2.1",
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",