reporting-labs 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -169,6 +169,7 @@ Run it two or three times to see the history features (new vs known failures, fl
169
169
 
170
170
  ### Test detail: the error, the steps, the proof
171
171
 
172
+ - **What went wrong, in plain words.** Above every error the report says what happened and what to check first: "Element not found: `#checkout` was not on the page within 5s", "Element covered by another element: a `<div class=overlay>` was on top", "Site unreachable", "Test timed out", "Assertion failed: expected "90", got "100"". Playwright's own message stays right below it, untouched. The same label shows in Needs attention and groups the Failure clusters.
172
173
  - **Expected vs received** side by side with the difference highlighted.
173
174
  - Error **location** (opens in VS Code), Playwright's **code snippet**, full message and stack.
174
175
  - **Steps** with a bar per step showing its share of the test time. Given/When/Then titles are styled as Gherkin.
@@ -224,7 +225,7 @@ Every option is optional. `npx reporting-labs init` writes them all, with commen
224
225
  | Option | Default | What it does |
225
226
  |---|---|---|
226
227
  | `title` | `'Test report'` | Title in the header |
227
- | `logo` | – | Path, URL or data URI of your logo |
228
+ | `logo` | – | Your logo next to the title: `'logo.png'` (a file next to the config, embedded in the report) or an https URL |
228
229
  | `project` | – | `{ name, version, team, url }` shown under the title |
229
230
  | `metadata` | `{}` | Chips in the header, e.g. `{ env: 'staging', build: '#1842' }`. `build` labels the run in the trend; in CI the run number is used when it is not set |
230
231
  | `env` | – | Extra rows on the Environment card |
@@ -232,7 +233,7 @@ Every option is optional. `npx reporting-labs init` writes them all, with commen
232
233
  | `maskKeys` | `[]` | Extra keys to mask as `****` |
233
234
  | `dimensions` | `['priority','severity','feature','owner']` | Meta keys that get charts and filters |
234
235
  | `dimensionOrder` | P0…P4, blocker…trivial | Sort order per dimension |
235
- | `widgets` | all on | Hide cards: `{ tags: false, timeline: false, ... }` |
236
+ | `widgets` | all on | Hide cards: `{ tags: false, timeline: false, ... }`. Failure clusters and the Trend chart are always shown |
236
237
  | `sections` | `[]` | Extra HTML below the summary, e.g. release notes |
237
238
  | `history` | `{ enabled: true, keep: 30 }` | Run history file; `file` sets a custom path |
238
239
  | `palette` | `'lab'` | `'lab'` (blue), `'ocean'`, `'ember'`, `'mono'` |
@@ -9,7 +9,7 @@ const config: ReportingLabsOptions = {
9
9
 
10
10
  // ── Look ─────────────────────────────────────────────────────────────────────
11
11
  title: 'My app – regression', // shown in the header
12
- // logo: 'https://example.com/logo.svg', // path, URL or data URI; shown next to the title
12
+ // logo: 'logo.png', // your logo next to the title: a file next to this config (embedded), or an https URL
13
13
  // palette: 'lab', // 'lab' (blue, default) | 'ocean' | 'ember' | 'mono'; viewers can switch
14
14
  // accent: '#7C3AED', // your brand color instead of the palette accent
15
15
  // theme: 'auto', // 'auto' (follows OS, default) | 'light' | 'dark'
@@ -54,6 +54,7 @@ const config: ReportingLabsOptions = {
54
54
  // },
55
55
 
56
56
  // ── Widgets on the overview (hide what you do not need) ──────────────────────
57
+ // Failure clusters and the Trend chart are always shown.
57
58
  // widgets: {
58
59
  // runStrip: true, // pass/fail strip under the header
59
60
  // outcome: true, // outcome ring
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Turns a raw Playwright error message into a short, plain-language explanation.
3
+ * Rule based, no network, no AI. The original message is always kept next to it.
4
+ */
5
+ export type ErrorKind = 'not-found' | 'ambiguous' | 'not-visible' | 'blocked' | 'disabled' | 'detached' | 'wrong-element' | 'assertion' | 'visual' | 'navigation' | 'network' | 'api' | 'test-timeout' | 'hook-timeout' | 'closed' | 'script' | 'file' | 'thrown';
6
+ export interface ErrorExplain {
7
+ kind: ErrorKind;
8
+ /** Short label for a badge, e.g. "Element not found". */
9
+ label: string;
10
+ /** One plain sentence about what happened. */
11
+ summary: string;
12
+ /** What to check first. */
13
+ hint?: string;
14
+ /** The locator involved, when Playwright printed one. */
15
+ locator?: string;
16
+ /** The Playwright call that failed, e.g. locator.click or page.goto. */
17
+ action?: string;
18
+ /** The expect matcher, e.g. toHaveText. */
19
+ matcher?: string;
20
+ timeoutMs?: number;
21
+ url?: string;
22
+ }
23
+ export declare function explainError(raw: string): ErrorExplain | undefined;
@@ -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; } });
@@ -31,6 +31,8 @@ export default class ReportingLabsReporter implements Reporter {
31
31
  private metaKeys;
32
32
  /** Pull meta values (priority, owner, story, epic...) from annotations and tags. */
33
33
  private extractMeta;
34
+ /** A local image file (path relative to the config) is embedded as a data URI so the report stays self-contained. URLs and data URIs pass through. */
35
+ private resolveLogo;
34
36
  private rel;
35
37
  private serializeError;
36
38
  private outcome;
package/dist/reporter.js CHANGED
@@ -39,6 +39,7 @@ const os = __importStar(require("os"));
39
39
  const child_process_1 = require("child_process");
40
40
  const template_1 = require("./template");
41
41
  const mask_1 = require("./mask");
42
+ const explain_1 = require("./explain");
42
43
  const DEFAULT_EMBED_LIMIT = 2 * 1024 * 1024;
43
44
  /** Attach steps created by log() and the automatic API capture; the data shows in its own section, so hide the step. */
44
45
  function isInternalAttach(s) {
@@ -167,7 +168,7 @@ class ReportingLabsReporter {
167
168
  globalOutput: this.globalOutput,
168
169
  shard: this.config.shard ? { current: this.config.shard.current, total: this.config.shard.total } : undefined,
169
170
  options: {
170
- logo: this.options.logo,
171
+ logo: this.resolveLogo(this.options.logo),
171
172
  accent: this.options.accent,
172
173
  theme: this.options.theme ?? 'auto',
173
174
  palette: this.options.palette ?? 'lab',
@@ -329,12 +330,33 @@ class ReportingLabsReporter {
329
330
  }
330
331
  return meta;
331
332
  }
333
+ /** A local image file (path relative to the config) is embedded as a data URI so the report stays self-contained. URLs and data URIs pass through. */
334
+ resolveLogo(logo) {
335
+ if (!logo || /^(https?:|data:)/i.test(logo))
336
+ return logo;
337
+ const base = this.config.configFile ? path.dirname(this.config.configFile) : this.config.rootDir;
338
+ const file = path.resolve(base, logo);
339
+ if (!fs.existsSync(file)) {
340
+ console.warn(`reporting-labs: logo not found at ${file}`);
341
+ return undefined;
342
+ }
343
+ const mime = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.svg': 'image/svg+xml', '.gif': 'image/gif', '.webp': 'image/webp', '.ico': 'image/x-icon' };
344
+ const type = mime[path.extname(file).toLowerCase()];
345
+ if (!type) {
346
+ console.warn(`reporting-labs: logo ${logo} is not a png, jpg, svg, gif or webp file`);
347
+ return undefined;
348
+ }
349
+ return `data:${type};base64,${fs.readFileSync(file).toString('base64')}`;
350
+ }
332
351
  rel(file) {
333
352
  const base = this.config.configFile ? path.dirname(this.config.configFile) : this.config.rootDir;
334
353
  return path.relative(base, file).split(path.sep).join('/');
335
354
  }
336
355
  serializeError(e) {
337
356
  const out = { message: stripAnsi(e.message ?? e.value ?? '') };
357
+ const why = (0, explain_1.explainError)(out.message);
358
+ if (why)
359
+ out.explain = why;
338
360
  if (e.stack)
339
361
  out.stack = stripAnsi(e.stack);
340
362
  if (e.snippet)
package/dist/template.js CHANGED
@@ -473,6 +473,7 @@ html,body{-webkit-font-smoothing:antialiased}
473
473
  .hdr h1,.hdr .proj-blk h1{color:#fff;font-size:20px;font-weight:600;letter-spacing:-.01em;line-height:1.2}
474
474
  .hdr .proj-blk .sub,.hdr .proj-blk .sub a,.hdr .when{color:rgba(255,255,255,.78)}
475
475
  .hdr .logo{width:42px;height:42px;border-radius:11px;flex:none;box-shadow:0 0 0 1px rgba(255,255,255,.35),0 6px 16px -6px rgba(0,0,0,.45)} .hdr .logo svg{width:42px;height:42px;display:block;border-radius:11px}
476
+ .hdr img.logo.brand{width:auto;max-width:180px;height:42px;object-fit:contain;border-radius:8px;background:#fff;padding:4px;box-sizing:border-box}
476
477
  .hdr img{height:32px;border-radius:6px}
477
478
  .hdr .chip{background:rgba(255,255,255,.12);border:1px solid rgba(255,255,255,.22);color:rgba(255,255,255,.85);border-radius:999px;padding:4px 9px}
478
479
  .hdr .chip b{color:#fff}
@@ -603,7 +604,7 @@ html,body{-webkit-font-smoothing:antialiased}
603
604
  .attn2 td:first-child{border-radius:6px 0 0 6px} .attn2 td:last-child{border-radius:0 6px 6px 0}
604
605
  .attn2 .pr{width:96px;white-space:nowrap;font-size:12.5px;line-height:1.35} .attn2 .pr .dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:7px}
605
606
  .attn2 .pr span{font-weight:600;color:var(--ink)} .attn2 .pr small{display:block;color:var(--ink-3);font-size:11.5px;padding-left:15px}
606
- .attn2 .tt .n{font-weight:500;color:var(--ink);line-height:1.35} .attn2 .tt .s{font-size:12px;color:var(--ink-3);margin-top:2px} .attn2 .tt .s b.new{color:var(--fail);font-weight:600}
607
+ .attn2 .tt .n{font-weight:500;color:var(--ink);line-height:1.35} .attn2 .tt .s{font-size:12px;color:var(--ink-3);margin-top:2px} .attn2 .tt .s b.new{color:var(--fail);font-weight:600} .attn2 .tt .s .rsn{color:var(--ink-2)}
607
608
  .attn2 .ow{text-align:right;color:var(--ink-3);font-size:12.5px;white-space:nowrap;width:1%;padding-top:10px}
608
609
  .attn-foot{margin-top:auto;padding-top:12px;border-top:1px solid var(--line)} .attn-foot button{font-size:12.5px;font-weight:500;color:var(--accent-2)} .attn-foot button:hover{text-decoration:underline}
609
610
  .clu2{list-style:none;margin:0;padding:0}
@@ -619,6 +620,12 @@ html,body{-webkit-font-smoothing:antialiased}
619
620
  .note{padding:10px 14px;border-radius:8px;margin-bottom:10px;border:1px solid var(--line)} .note.ok{background:var(--pass-bg);color:var(--ink)} .note.warn{background:var(--flaky-bg);color:var(--flaky-ink)}
620
621
  .badge.xfail{background:var(--flaky-bg);color:var(--flaky-ink)}
621
622
  .errwrap+.errwrap{margin-top:14px}
623
+ .why{border:1px solid var(--line);border-left:3px solid var(--fail);border-radius:0 var(--radius) var(--radius) 0;padding:10px 14px;margin-bottom:10px;background:var(--surface)}
624
+ .why-c{font-size:12.5px;color:var(--ink);margin-bottom:2px;line-height:1.4} .why-c .why-k{margin-right:2px}
625
+ .why-l{display:flex;align-items:center;gap:8px;margin-bottom:4px} .why-k{font-size:12px;font-weight:600;color:var(--fail)} .why-a{font:11.5px var(--mono);color:var(--ink-3)}
626
+ .why-s{font-size:var(--fs);color:var(--ink);line-height:1.45} .why-h{font-size:12.5px;color:var(--ink-2);margin-top:4px;line-height:1.45}
627
+ .why.assertion,.why.visual{border-left-color:#C77D14} .why.assertion .why-k,.why.visual .why-k{color:#9A6A00}
628
+ .why.script,.why.thrown,.why.file{border-left-color:var(--ink-3)} .why.script .why-k,.why.thrown .why-k,.why.file .why-k{color:var(--ink-2)}
622
629
  .errloc{font-family:var(--mono);color:var(--ink-3);margin-bottom:6px} .errloc a{color:var(--accent-2);text-decoration:none} .errloc a:hover{text-decoration:underline}
623
630
  .snip{margin:10px 0 0;background:var(--surface-2);border:1px solid var(--line);border-radius:8px;padding:10px 12px;font-family:var(--mono);line-height:1.55;white-space:pre;overflow:auto;color:var(--ink-2)}
624
631
  /* ---------- triage extras: history, diff, export, env, heatmap ---------- */
@@ -725,7 +732,7 @@ app.append(h('div',{class:'band'}, stripe(), header(), nav()),
725
732
  h('div',{class:'view','data-view':'tests'}, main()),
726
733
  h('div',{class:'view','data-view':'failures'}, h('div',{class:'view-pad'}, failuresView())),
727
734
  h('div',{class:'view','data-view':'api'}, h('div',{class:'view-pad'}, apiView())),
728
- h('div',{class:'view','data-view':'timeline'}, h('div',{class:'view-pad'}, timelineView())));
735
+ hasTimelineView()? h('div',{class:'view','data-view':'timeline'}, h('div',{class:'view-pad'}, timelineView())) : null);
729
736
  select(state.selected || firstInteresting(), true);
730
737
  showView(state.view);
731
738
  window.addEventListener('keydown', e=>{
@@ -740,13 +747,14 @@ window.addEventListener('keydown', e=>{
740
747
  /* ---------- header ---------- */
741
748
  function setHash(){ const p=new URLSearchParams(); if(state.view!=='overview') p.set('view',state.view); if(state.selected&&state.view==='tests') p.set('t',state.selected); const hs=p.toString(); history.replaceState(null,'',hs?'#'+hs:location.pathname+location.search); }
742
749
  function showView(v){
750
+ if(!document.querySelector('.view[data-view='+v+']')) v='overview';
743
751
  state.view=v; document.querySelectorAll('.view').forEach(el=>el.classList.toggle('on',el.dataset.view===v));
744
752
  document.querySelectorAll('.nav button[data-view]').forEach(b=>b.setAttribute('aria-selected',b.dataset.view===v));
745
753
  setHash(); try{window.scrollTo(0,0);}catch(e){}
746
754
  }
747
755
  function nav(){
748
756
  const s=data.stats, f=s.failed+s.timedOut+s.interrupted, apiN=data.tests.reduce((a,t)=>a+t.results.reduce((b,r)=>b+r.api.length,0),0);
749
- const tabs=[['overview','Overview',null],['tests','Tests',s.total],['failures','Failures',f+s.flaky,f>0],['api','API',apiN],['timeline','Timeline',null]].filter(t=>t[0]!=='api'||apiN>0);
757
+ const tabs=[['overview','Overview',null],['tests','Tests',s.total],['failures','Failures',f+s.flaky,f>0],['api','API',apiN],['timeline','Timeline',null]].filter(t=>(t[0]!=='api'||apiN>0)&&(t[0]!=='timeline'||hasTimelineView()));
750
758
  return h('nav',{class:'nav'}, h('div',{}, tabs.map(([v,l,n,bad])=>h('button',{'data-view':v,'aria-selected':state.view===v,onclick:()=>showView(v)}, l, n!=null?h('span',{class:'cnt'+(bad?' bad':'')},n):null))));
751
759
  }
752
760
  function failuresView(){
@@ -780,8 +788,12 @@ function apiView(){
780
788
  h('td',{class:'u',title:c.url},c.url), h('td',{}, h('span',{class:'sc '+cls,style:'font:12px var(--mono);font-weight:600;color:'+(cls==='bad'?'var(--fail)':cls==='warn'?'var(--flaky-ink)':'var(--pass)')},st||'—')), h('td',{class:'t'},c.duration!=null?ms(c.duration):'—'), h('td',{class:'t',title:t.title},t.title)); })))));
781
789
  }
782
790
  function timelineView(){
783
- return h('div',{class:'grid'}, h('div',{class:'card w12'}, h('h2',{},'Timeline by worker'), timeline()), h('div',{class:'card w6'}, h('h2',{},'Workers'), workers()), h('div',{class:'card w6'}, h('h2',{},'Duration spread'), histogram()));
791
+ const W=data.options.widgets, cards=[];
792
+ if(W.timeline!==false) cards.push(h('div',{class:'card w12'}, h('h2',{},'Timeline by worker'), timeline()), h('div',{class:'card w6'}, h('h2',{},'Workers'), workers()));
793
+ if(W.durations!==false) cards.push(h('div',{class:W.timeline!==false?'card w6':'card w12'}, h('h2',{},'Duration spread'), histogram()));
794
+ return h('div',{class:'grid'}, cards);
784
795
  }
796
+ function hasTimelineView(){ return data.options.widgets.timeline!==false || data.options.widgets.durations!==false; }
785
797
  function stripe(){
786
798
  const s=data.stats, f=s.failed+s.timedOut+s.interrupted, tot=s.total||1;
787
799
  return h('div',{class:'stripe','aria-hidden':'true'}, [['passed',s.passed],['flaky',s.flaky],['failed',f],['skipped',s.skipped]].filter(x=>x[1]).map(([k,n])=>h('i',{style:'width:'+(n/tot*100)+'%;background:'+colorOf(k)})));
@@ -808,10 +820,11 @@ function header(){
808
820
  [['lab','Blue'],['ocean','Ocean'],['ember','Ember'],['mono','Mono']].map(([v,l])=>h('option',{value:v,selected:html.getAttribute('data-palette')===v},l)));
809
821
  const copy = h('button',{class:'btn',onclick:e=>copyText(summaryMarkdown(),e.currentTarget,'Copied for Slack')}, 'Copy summary');
810
822
  return h('header',{class:'hdr'},
811
- data.options.logo ? h('img',{src:data.options.logo,alt:''}) : h('div',{class:'logo',title:'Generated by reportingLabs','aria-label':'reportingLabs'}, document.getElementById('rl-logo').content.cloneNode(true)),
823
+ data.options.logo ? h('img',{class:'logo brand',src:data.options.logo,alt:''}) : h('div',{class:'logo',title:'Generated by reportingLabs','aria-label':'reportingLabs'}, document.getElementById('rl-logo').content.cloneNode(true)),
812
824
  data.options.project ? h('div',{class:'proj-blk'}, h('h1',{}, data.title),
813
825
  h('div',{class:'sub'}, [data.options.project.name, data.options.project.version?'v'+data.options.project.version:null, data.options.project.team].filter(Boolean).join(' · '),
814
- data.options.project.url? [' · ', h('a',{href:data.options.project.url,target:'_blank',rel:'noopener'}, data.options.project.url.replace(/^https?:\/\//,''))] : null))
826
+ data.options.project.url? [' · ', h('a',{href:data.options.project.url,target:'_blank',rel:'noopener'}, data.options.project.url.replace(/^https?:\/\//,''))] : null),
827
+ data.options.project.description? h('div',{class:'sub desc'}, data.options.project.description) : null)
815
828
  : h('h1',{}, data.title),
816
829
  h('div',{class:'hmeta'}, Object.entries(data.metadata).map(([k,v])=>h('span',{class:'chip'}, k+' ', h('b',{},v)))),
817
830
  h('div',{class:'spacer'}),
@@ -950,7 +963,7 @@ function csvCell(v){ v=v==null?'':String(v); return /[",\n]/.test(v)? '"'+v.repl
950
963
  function failureRows(){
951
964
  const bad=data.tests.filter(t=>isFail(t.outcome)||t.outcome==='flaky').sort((a,b)=>rank(a)-rank(b));
952
965
  return bad.map(t=>{ const r=t.results[t.results.length-1], e=r&&r.errors[0], si=sinceInfo(t);
953
- return { title:t.title, suite:t.path.join(' > '), file:t.file, line:t.line, project:t.project, outcome:label[t.outcome], since:si?(si.kind==='new'?'new':runLabel(si.since)):'', priority:t.meta.priority||'', severity:t.meta.severity||'', owner:t.meta.owner||'', feature:t.meta.feature||'', story:t.meta.story||t.meta.issue||'', epic:t.meta.epic||'', attempts:t.results.length, duration_ms:Math.round(t.duration), error:e?e.message.split('\n')[0]:'', tags:t.tags.join(' ') }; });
966
+ return { title:t.title, suite:t.path.join(' > '), file:t.file, line:t.line, project:t.project, outcome:label[t.outcome], since:si?(si.kind==='new'?'new':runLabel(si.since)):'', priority:t.meta.priority||'', severity:t.meta.severity||'', owner:t.meta.owner||'', feature:t.meta.feature||'', story:t.meta.story||t.meta.issue||'', epic:t.meta.epic||'', attempts:t.results.length, duration_ms:Math.round(t.duration), reason:e&&e.explain?e.explain.label:'', why:e&&e.explain?e.explain.summary:'', error:e?e.message.split('\n')[0]:'', tags:t.tags.join(' ') }; });
954
967
  }
955
968
  function exportCsv(){ const rows=failureRows(); if(!rows.length) return; const cols=Object.keys(rows[0]); download(fileStem()+'-failures.csv', [cols.join(','), ...rows.map(r=>cols.map(c=>csvCell(r[c])).join(','))].join('\n'), 'text/csv'); }
956
969
  function exportJson(){ const rows=failureRows().map(r=>{ const t=data.tests.find(x=>x.title===r.title&&x.project===r.project&&x.file===r.file); const res=t&&t.results[t.results.length-1]; return Object.assign({}, r, {errorFull:res&&res.errors[0]?res.errors[0].message:''}); });
@@ -969,7 +982,12 @@ function tokenDiff(a,b){
969
982
  const join=(arr,cls)=>{ const out=[]; for(const [t,d] of arr){ const last=out[out.length-1]; if(last&&last.d===d) last.t+=t; else out.push({t,d}); } return out.map(x=>x.d?h('mark',{class:cls},x.t):x.t); };
970
983
  return {a:join(A,'del'), b:join(B,'ins')};
971
984
  }
972
- function errorView(e){ return h('div',{class:'errwrap'}, errorWhere(e), errorBody(e), e.snippet? h('pre',{class:'snip'}, e.snippet) : null); }
985
+ function errorView(e){ return h('div',{class:'errwrap'}, whyView(e.explain), errorWhere(e), errorBody(e), e.snippet? h('pre',{class:'snip'}, e.snippet) : null); }
986
+ function whyView(x){
987
+ if(!x) return null;
988
+ return h('div',{class:'why '+x.kind}, h('div',{class:'why-l'}, h('span',{class:'why-k'},x.label), x.action? h('code',{class:'why-a'},x.action):null),
989
+ h('div',{class:'why-s'},x.summary), x.hint? h('div',{class:'why-h'},x.hint):null);
990
+ }
973
991
  function errorWhere(e){
974
992
  if(!e.location) return null;
975
993
  const l=e.location, txt=l.file+':'+l.line+(l.column?':'+l.column:'');
@@ -1041,7 +1059,7 @@ function hero(){
1041
1059
  else delta=ok? 'Nothing to worry about.' : failed+' failed'+(s.flaky?', '+s.flaky+' flaky':'')+(s.skipped?', '+s.skipped+' skipped':'');
1042
1060
  return h('button',{class:'pill kpi hero','aria-pressed':state.status==='all','data-k':'all',title:'Show all tests',onclick:()=>{state.status='all';refresh();showView('tests');}},
1043
1061
  spark(hseries(e=>{ const rn=e.total-e.skipped; return rn?Math.round(e.passed/rn*100):0; }),'#fff'),
1044
- ring,
1062
+ data.options.widgets.outcome!==false? ring : null,
1045
1063
  h('div',{class:'body'},
1046
1064
  h('div',{class:'l'}, ok? 'All passed' : 'Pass rate'),
1047
1065
  h('div',{class:'v'}, s.passed, h('small',{}, 'of '+ran+' ran')),
@@ -1067,6 +1085,7 @@ function attention(limit){
1067
1085
  const sub=(t,projects)=>{ const parts=[t.file.split('/').pop()];
1068
1086
  if(data.projects.length>1) parts.push(projects.length>1? projects.length+' projects' : projects[0]);
1069
1087
  const si=sinceInfo(t); if(si) parts.push(si.kind==='new'? h('b',{class:'new'},'new this run') : 'failing since '+runLabel(si.since));
1088
+ const le=t.results[t.results.length-1]&&t.results[t.results.length-1].errors[0]; if(le&&le.explain) parts.push(h('span',{class:'rsn',title:le.explain.summary},le.explain.label));
1070
1089
  else if(t.outcome==='flaky') parts.push('passed on retry '+t.results.length);
1071
1090
  const out=[]; parts.forEach((x,k)=>{ if(k) out.push(' · '); out.push(x); }); return out; };
1072
1091
  const tbl=h('table',{class:'attn2'}, h('tbody',{}, rows.map(({t,projects})=>h('tr',{tabindex:'0',onclick:()=>select(t.id),onkeydown:e=>{ if(e.key==='Enter') select(t.id); }},
@@ -1121,7 +1140,7 @@ function errorSignature(msg){
1121
1140
  }
1122
1141
  function failureClusters(){
1123
1142
  const m=new Map();
1124
- for(const t of data.tests){ if(!isFail(t.outcome)) continue; const r=t.results[t.results.length-1]; const e=r&&r.errors[0]; const sig=errorSignature(e?e.message:'(no error message)'); const c=m.get(sig)||{sig,sample:e?e.message.split('\n').slice(0,2).join('\n'):'(no error message)',tests:[]}; c.tests.push(t); m.set(sig,c); }
1143
+ for(const t of data.tests){ if(!isFail(t.outcome)) continue; const r=t.results[t.results.length-1]; const e=r&&r.errors[0]; const x=e&&e.explain; const sig=x&&!/^(thrown|script)$/.test(x.kind)? x.kind+'|'+(x.locator||x.url||'')+'|'+(x.matcher||x.action||'') : errorSignature(e?e.message:'(no error message)'); const c=m.get(sig)||{sig,sample:e?e.message.split('\n').slice(0,2).join('\n'):'(no error message)',why:e&&e.explain?e.explain:null,tests:[]}; c.tests.push(t); m.set(sig,c); }
1125
1144
  return [...m.values()].sort((a,b)=>b.tests.length-a.tests.length);
1126
1145
  }
1127
1146
  function clustersView(cl){
@@ -1131,7 +1150,7 @@ function clustersView(cl){
1131
1150
  const who=[]; names.forEach(([title,ts],k)=>{ if(k) who.push(h('span',{class:'sep'},'·')); who.push(h('button',{onclick:()=>select(ts[0].id),title:ts.map(t=>t.project).join(', ')}, title, data.projects.length>1&&ts.length>1? h('small',{},' ×'+ts.length) : null)); });
1132
1151
  if(extra>0) who.push(h('span',{class:'sep'},'·'), h('span',{class:'ex'},'+'+extra+' more'));
1133
1152
  return h('li',{}, h('span',{class:'n'}, c.tests.length, h('small',{},c.tests.length===1?'test':'tests')),
1134
- h('div',{class:'b'}, h('div',{class:'msg',title:c.sample}, c.sample.split('\n')[0]), h('div',{class:'who'}, who)));
1153
+ h('div',{class:'b'}, c.why? h('div',{class:'why-c'}, h('span',{class:'why-k'},c.why.label), ' ', c.why.summary) : null, h('div',{class:'msg',title:c.sample}, c.sample.split('\n')[0]), h('div',{class:'who'}, who)));
1135
1154
  }));
1136
1155
  }
1137
1156
  function trend(){
package/dist/types.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  export interface ReportingLabsOptions {
2
2
  /** Report title shown in the header. Default: "Test report" */
3
3
  title?: string;
4
- /** Path or data-URI of a logo image. Optional. */
4
+ /** Your logo: a file path relative to the config (e.g. 'logo.png', embedded into the report), an https URL, or a data URI. */
5
5
  logo?: string;
6
6
  /** Override the palette accent with your brand color, e.g. '#7C3AED'. Default: the palette's own accent (royal blue for `lab`). */
7
7
  accent?: string;
@@ -148,6 +148,17 @@ export interface EnvRow {
148
148
  href?: string;
149
149
  }
150
150
  export type Status = 'passed' | 'failed' | 'skipped' | 'flaky' | 'timedOut' | 'interrupted';
151
+ export interface ErrorExplainData {
152
+ kind: string;
153
+ label: string;
154
+ summary: string;
155
+ hint?: string;
156
+ locator?: string;
157
+ action?: string;
158
+ matcher?: string;
159
+ timeoutMs?: number;
160
+ url?: string;
161
+ }
151
162
  export interface ErrorData {
152
163
  message: string;
153
164
  stack?: string;
@@ -156,7 +167,8 @@ export interface ErrorData {
156
167
  file: string;
157
168
  line: number;
158
169
  column: number;
159
- };
170
+ }; /** Plain-language reading of the message, rule based. The message itself is always kept. */
171
+ explain?: ErrorExplainData;
160
172
  }
161
173
  export interface StepData {
162
174
  title: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reporting-labs",
3
- "version": "0.2.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",