reporting-labs 0.6.11 → 0.6.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/explain.d.ts +1 -1
- package/dist/explain.js +4 -0
- package/dist/pdf.d.ts +14 -0
- package/dist/pdf.js +149 -0
- package/dist/reporter.d.ts +1 -2
- package/dist/reporter.js +8 -24
- package/dist/template.html +98 -30
- package/dist/template.js +98 -30
- package/dist/types.d.ts +5 -2
- package/dist/wdio/finalize.d.ts +1 -0
- package/dist/wdio/finalize.js +9 -23
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -328,7 +328,7 @@ Every option is optional. `npx reporting-labs init` writes them all, with commen
|
|
|
328
328
|
| `embedVideos` | `false` | Videos inside the HTML too (bigger file, no folder issues) |
|
|
329
329
|
| `emitJson` | `true` | Also write `report.json` alongside `index.html` (used by `merge`) |
|
|
330
330
|
| `jsonFile` | `'report.json'` | File name of the JSON blob |
|
|
331
|
-
| `pdf` | `true` | Also write `report.pdf` (light theme, print-ready)
|
|
331
|
+
| `pdf` | `true` | Also write `report.pdf` (light theme, print-ready), printed by Playwright's Chromium or an installed Chrome / Edge, whatever browser the tests ran on. `false` turns it off; `{ file: 'run.pdf' }` renames it; `{ chromePath }` (or `CHROME_PATH`) names the browser. The Export PDF button in the report works either way |
|
|
332
332
|
| `embedFonts` | `true` | Bundle the fonts (~140 KB) so it looks the same offline |
|
|
333
333
|
| `announce` | `true` | Print the report path after the run |
|
|
334
334
|
| `open` | `'on-failure'` | Open the report in the browser after the run: `'on-failure'`, `'always'` or `'never'`. Never opens in CI |
|
package/dist/explain.d.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Turns a raw Playwright error message into a short, plain-language explanation.
|
|
3
3
|
* Rule based, no network, no AI. The original message is always kept next to it.
|
|
4
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';
|
|
5
|
+
export type ErrorKind = 'not-found' | 'ambiguous' | 'not-visible' | 'blocked' | 'disabled' | 'detached' | 'wrong-element' | 'assertion' | 'visual' | 'navigation' | 'network' | 'api' | 'test-timeout' | 'hook-timeout' | 'closed' | 'crashed' | 'script' | 'file' | 'thrown';
|
|
6
6
|
export interface ErrorExplain {
|
|
7
7
|
kind: ErrorKind;
|
|
8
8
|
/** Short label for a badge, e.g. "Element not found". */
|
package/dist/explain.js
CHANGED
|
@@ -21,6 +21,7 @@ const LABELS = {
|
|
|
21
21
|
'test-timeout': 'Test timed out',
|
|
22
22
|
'hook-timeout': 'Hook timed out',
|
|
23
23
|
'closed': 'Browser closed early',
|
|
24
|
+
'crashed': 'Browser crashed',
|
|
24
25
|
'script': 'Error in test code',
|
|
25
26
|
'file': 'File not found',
|
|
26
27
|
'thrown': 'Test threw an error',
|
|
@@ -51,6 +52,9 @@ function explainError(raw) {
|
|
|
51
52
|
const where = action ? ` It was stuck in ${action}` + (locator ? ` on ${locator}` : '') + '.' : '';
|
|
52
53
|
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
|
}
|
|
55
|
+
// ── The browser process died (memory, too many workers): infrastructure, not the app or the test ──
|
|
56
|
+
if (/Target crashed|Page crashed|page has crashed|Renderer process crashed|tab crashed|session deleted because of page crash|chrome not reachable|Browsing context has been discarded/i.test(msg))
|
|
57
|
+
return out('crashed', 'The browser crashed while the test was running.', 'Not a bug in the app or the test: the browser process died, usually from memory pressure or too many parallel workers. Re-run it; if it keeps happening, lower workers or give the machine more memory.', { action });
|
|
54
58
|
// ── Strict mode ──────────────────────────────────────────────────────────
|
|
55
59
|
m = msg.match(/strict mode violation: (.+?) resolved to (\d+) elements/s);
|
|
56
60
|
if (m)
|
package/dist/pdf.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export interface PdfResult {
|
|
2
|
+
ok: boolean;
|
|
3
|
+
reason?: string;
|
|
4
|
+
}
|
|
5
|
+
/** Render the report's print layout to a PDF. Best-effort, never throws: a failure here must not fail
|
|
6
|
+
* the run — the HTML report and its Export PDF button remain.
|
|
7
|
+
* 1. the Chromium that Playwright ships, when `@playwright/test` and its browser are installed;
|
|
8
|
+
* 2. an installed Chrome, Edge or Chromium, headless with --print-to-pdf (the report builds its print
|
|
9
|
+
* layout itself on `?rl-print`). Edge ships with Windows, so this covers suites that run on Firefox
|
|
10
|
+
* or WebKit, and WebdriverIO projects that have no Playwright at all.
|
|
11
|
+
* `chromePath` (or the CHROME_PATH variable) names the browser explicitly and skips the search. */
|
|
12
|
+
export declare function writeReportPdf(htmlFile: string, pdfFile: string, chromePath?: string): Promise<PdfResult>;
|
|
13
|
+
/** Any Chromium-based browser can print the report: Chrome, Edge or Chromium. */
|
|
14
|
+
export declare function findChrome(): string | undefined;
|
package/dist/pdf.js
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.writeReportPdf = writeReportPdf;
|
|
37
|
+
exports.findChrome = findChrome;
|
|
38
|
+
const fs = __importStar(require("fs"));
|
|
39
|
+
const path = __importStar(require("path"));
|
|
40
|
+
const child_process_1 = require("child_process");
|
|
41
|
+
const url_1 = require("url");
|
|
42
|
+
/** Render the report's print layout to a PDF. Best-effort, never throws: a failure here must not fail
|
|
43
|
+
* the run — the HTML report and its Export PDF button remain.
|
|
44
|
+
* 1. the Chromium that Playwright ships, when `@playwright/test` and its browser are installed;
|
|
45
|
+
* 2. an installed Chrome, Edge or Chromium, headless with --print-to-pdf (the report builds its print
|
|
46
|
+
* layout itself on `?rl-print`). Edge ships with Windows, so this covers suites that run on Firefox
|
|
47
|
+
* or WebKit, and WebdriverIO projects that have no Playwright at all.
|
|
48
|
+
* `chromePath` (or the CHROME_PATH variable) names the browser explicitly and skips the search. */
|
|
49
|
+
async function writeReportPdf(htmlFile, pdfFile, chromePath) {
|
|
50
|
+
try {
|
|
51
|
+
fs.unlinkSync(pdfFile);
|
|
52
|
+
}
|
|
53
|
+
catch { /* none yet */ } // never leave a previous run's PDF next to this report
|
|
54
|
+
const explicit = chromePath || process.env.CHROME_PATH;
|
|
55
|
+
let firstError = '';
|
|
56
|
+
if (!explicit) {
|
|
57
|
+
try {
|
|
58
|
+
const { chromium } = await import('@playwright/test');
|
|
59
|
+
const browser = await chromium.launch();
|
|
60
|
+
try {
|
|
61
|
+
const page = await browser.newPage();
|
|
62
|
+
await page.goto((0, url_1.pathToFileURL)(htmlFile).href, { waitUntil: 'load' });
|
|
63
|
+
await page.evaluate(() => window.reportingLabsPreparePrint?.());
|
|
64
|
+
await page.pdf({ path: pdfFile, printBackground: true, preferCSSPageSize: true });
|
|
65
|
+
}
|
|
66
|
+
finally {
|
|
67
|
+
await browser.close();
|
|
68
|
+
}
|
|
69
|
+
if (written(pdfFile))
|
|
70
|
+
return { ok: true };
|
|
71
|
+
}
|
|
72
|
+
catch (e) {
|
|
73
|
+
firstError = String(e.message).split('\n')[0];
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const chrome = explicit && isFile(explicit) ? explicit : findChrome();
|
|
77
|
+
if (!chrome) {
|
|
78
|
+
return { ok: false, reason: explicit ? `browser not found at ${explicit}` : 'no Chrome, Edge or Chromium found (set pdf: { chromePath }, or pdf: false to silence)' };
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
(0, child_process_1.spawnSync)(chrome, ['--headless', '--no-sandbox', '--disable-gpu', '--no-pdf-header-footer',
|
|
82
|
+
'--virtual-time-budget=8000', '--run-all-compositor-stages-before-draw',
|
|
83
|
+
'--print-to-pdf=' + pdfFile, (0, url_1.pathToFileURL)(htmlFile).href + '?rl-print=1'], { stdio: 'ignore', timeout: 120000 });
|
|
84
|
+
}
|
|
85
|
+
catch (e) {
|
|
86
|
+
return { ok: false, reason: String(e.message).split('\n')[0] };
|
|
87
|
+
}
|
|
88
|
+
return written(pdfFile) ? { ok: true } : { ok: false, reason: firstError || `${path.basename(chrome)} did not write the PDF` };
|
|
89
|
+
}
|
|
90
|
+
function written(f) {
|
|
91
|
+
try {
|
|
92
|
+
return fs.statSync(f).size > 0;
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
function isFile(f) {
|
|
99
|
+
try {
|
|
100
|
+
return fs.statSync(f).isFile();
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
function which(name) {
|
|
107
|
+
const exts = process.platform === 'win32' ? ['.exe', ''] : [''];
|
|
108
|
+
for (const dir of (process.env.PATH || '').split(path.delimiter)) {
|
|
109
|
+
if (!dir)
|
|
110
|
+
continue;
|
|
111
|
+
for (const ext of exts) {
|
|
112
|
+
const p = path.join(dir, name + ext);
|
|
113
|
+
try {
|
|
114
|
+
fs.accessSync(p, fs.constants.X_OK);
|
|
115
|
+
if (isFile(p))
|
|
116
|
+
return p;
|
|
117
|
+
}
|
|
118
|
+
catch { /* next */ }
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
/** Any Chromium-based browser can print the report: Chrome, Edge or Chromium. */
|
|
124
|
+
function findChrome() {
|
|
125
|
+
const candidates = [];
|
|
126
|
+
if (process.platform === 'darwin') {
|
|
127
|
+
candidates.push('/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '/Applications/Chromium.app/Contents/MacOS/Chromium', '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge');
|
|
128
|
+
}
|
|
129
|
+
else if (process.platform === 'win32') {
|
|
130
|
+
for (const base of [process.env.ProgramFiles, process.env['ProgramFiles(x86)'], process.env.LOCALAPPDATA]) {
|
|
131
|
+
if (!base)
|
|
132
|
+
continue;
|
|
133
|
+
candidates.push(path.join(base, 'Google', 'Chrome', 'Application', 'chrome.exe'), path.join(base, 'Microsoft', 'Edge', 'Application', 'msedge.exe'));
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
for (const c of candidates)
|
|
137
|
+
if (isFile(c))
|
|
138
|
+
return c;
|
|
139
|
+
for (const name of ['google-chrome', 'google-chrome-stable', 'chromium', 'chromium-browser', 'chrome',
|
|
140
|
+
'microsoft-edge', 'microsoft-edge-stable', 'msedge']) {
|
|
141
|
+
const p = which(name);
|
|
142
|
+
if (p)
|
|
143
|
+
return p;
|
|
144
|
+
}
|
|
145
|
+
for (const c of ['/usr/bin/google-chrome', '/usr/bin/chromium', '/usr/bin/chromium-browser', '/snap/bin/chromium'])
|
|
146
|
+
if (isFile(c))
|
|
147
|
+
return c;
|
|
148
|
+
return undefined;
|
|
149
|
+
}
|
package/dist/reporter.d.ts
CHANGED
|
@@ -20,8 +20,7 @@ export default class ReportingLabsReporter implements Reporter {
|
|
|
20
20
|
private pushOutput;
|
|
21
21
|
onBegin(config: FullConfig, suite: Suite): void;
|
|
22
22
|
onEnd(result: FullResult): Promise<void>;
|
|
23
|
-
/**
|
|
24
|
-
* a failure here (no browser, sandbox) never fails the run — the report and its Export PDF button remain. */
|
|
23
|
+
/** report.pdf next to the HTML; see writeReportPdf for which browser prints it. Never fails the run. */
|
|
25
24
|
private writePdf;
|
|
26
25
|
/** One short list of tests that carry no meta() at all, so the whole team keeps the report useful. */
|
|
27
26
|
private printMissingMeta;
|
package/dist/reporter.js
CHANGED
|
@@ -44,6 +44,7 @@ const child_process_1 = require("child_process");
|
|
|
44
44
|
const template_1 = require("./template");
|
|
45
45
|
const mask_1 = require("./mask");
|
|
46
46
|
const explain_1 = require("./explain");
|
|
47
|
+
const pdf_1 = require("./pdf");
|
|
47
48
|
const DEFAULT_EMBED_LIMIT = 2 * 1024 * 1024;
|
|
48
49
|
/** Attach steps created by log() and the automatic API capture; the data shows in its own section, so hide the step. */
|
|
49
50
|
function isInternalAttach(s) {
|
|
@@ -247,31 +248,14 @@ class ReportingLabsReporter {
|
|
|
247
248
|
await this.writePdf(file);
|
|
248
249
|
this.maybeOpen(file, result);
|
|
249
250
|
}
|
|
250
|
-
/**
|
|
251
|
-
* a failure here (no browser, sandbox) never fails the run — the report and its Export PDF button remain. */
|
|
251
|
+
/** report.pdf next to the HTML; see writeReportPdf for which browser prints it. Never fails the run. */
|
|
252
252
|
async writePdf(htmlFile) {
|
|
253
|
-
const
|
|
254
|
-
const pdfFile = path.join(this.outDir,
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
try {
|
|
260
|
-
const page = await browser.newPage();
|
|
261
|
-
await page.goto(pathToFileURL(htmlFile).href, { waitUntil: 'load' });
|
|
262
|
-
await page.evaluate(() => window.reportingLabsPreparePrint?.());
|
|
263
|
-
await page.pdf({ path: pdfFile, printBackground: true, preferCSSPageSize: true });
|
|
264
|
-
}
|
|
265
|
-
finally {
|
|
266
|
-
await browser.close();
|
|
267
|
-
}
|
|
268
|
-
if (this.options.announce !== false)
|
|
269
|
-
console.log(` reporting-labs: PDF written to ${path.relative(process.cwd(), pdfFile)}\n`);
|
|
270
|
-
}
|
|
271
|
-
catch (e) {
|
|
272
|
-
if (this.options.announce !== false)
|
|
273
|
-
console.log(` reporting-labs: PDF skipped — ${String(e.message).split('\n')[0]}\n`);
|
|
274
|
-
}
|
|
253
|
+
const pdfOpt = typeof this.options.pdf === 'object' ? this.options.pdf : {};
|
|
254
|
+
const pdfFile = path.join(this.outDir, pdfOpt.file || 'report.pdf');
|
|
255
|
+
const r = await (0, pdf_1.writeReportPdf)(htmlFile, pdfFile, pdfOpt.chromePath);
|
|
256
|
+
if (this.options.announce === false)
|
|
257
|
+
return;
|
|
258
|
+
console.log(r.ok ? ` reporting-labs: PDF written to ${path.relative(process.cwd(), pdfFile)}\n` : ` reporting-labs: PDF skipped — ${r.reason}\n`);
|
|
275
259
|
}
|
|
276
260
|
/** One short list of tests that carry no meta() at all, so the whole team keeps the report useful. */
|
|
277
261
|
printMissingMeta(tests) {
|
package/dist/template.html
CHANGED
|
@@ -344,6 +344,10 @@ li.collapsed>ul{display:none}
|
|
|
344
344
|
.wk .row{display:grid;grid-template-columns:36px 1fr 120px;gap:10px;align-items:center;font-size:12px}
|
|
345
345
|
.wk .k{font:12px var(--mono);color:var(--ink-3)} .wk .bar{height:12px;display:flex;border-radius:3px;overflow:hidden;background:var(--skip-bg)} .wk .bar i{display:block;height:100%}
|
|
346
346
|
.wk .n{font:11px var(--mono);color:var(--ink-3);text-align:right}
|
|
347
|
+
.wk .row{grid-template-columns:36px 1fr auto} .wk .n{white-space:nowrap}
|
|
348
|
+
.proj-sfx{font-weight:400;color:var(--ink-3);font-size:.9em}
|
|
349
|
+
/* the Breakdown card keeps its own height: a two-priority suite should not leave a tall empty card */
|
|
350
|
+
.bd-card{align-self:start}
|
|
347
351
|
.wk-sum{font-size:13px;color:var(--ink-2);margin-bottom:10px}
|
|
348
352
|
/* list: group toggle + folder tree */
|
|
349
353
|
.tools .seg,.bug-tools .seg{display:inline-flex;border:1px solid var(--line);border-radius:var(--radius);overflow:hidden}
|
|
@@ -379,7 +383,9 @@ table.tbl{border-collapse:collapse;font-size:12.5px;width:100%;min-width:100%}
|
|
|
379
383
|
.api-head .m{font:11px/1 var(--mono);font-weight:600;padding:4px 7px;border-radius:3px;color:#fff;background:var(--ink-3)}
|
|
380
384
|
.api-head .m.GET{background:#2A7FBF} .api-head .m.POST{background:var(--pass)} .api-head .m.PUT,.api-head .m.PATCH{background:#C77D14} .api-head .m.DELETE{background:var(--fail)}
|
|
381
385
|
.api-head .u{flex:1;font:12px var(--mono);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
382
|
-
.api-head .sc{font:12px var(--mono);font-weight:600} .api-head .sc.ok{color:var(--pass)} .api-head .sc.bad{color:var(--fail)} .api-head .sc.warn{color:#9A6A00}
|
|
386
|
+
.api-head .sc{font:12px var(--mono);font-weight:600} .api-head .sc.ok{color:var(--pass)} .api-head .sc.bad{color:var(--fail)} .api-head .sc.warn{color:#9A6A00} .api-head .sc.exp{color:var(--ink-2)}
|
|
387
|
+
.sc-exp{margin-left:6px;font:500 10.5px var(--sans);color:var(--ink-3);background:var(--surface-2);border:1px solid var(--line);border-radius:999px;padding:1px 7px;vertical-align:1px}
|
|
388
|
+
.api-stats .exp b{color:var(--ink-2)} .api-proj{color:var(--ink-3)}
|
|
383
389
|
.api-head .d{font:11px var(--mono);color:var(--ink-3)}
|
|
384
390
|
.api-body{display:grid;grid-template-columns:1fr 1fr;gap:0}
|
|
385
391
|
@media (max-width:900px){.api-body{grid-template-columns:1fr}}
|
|
@@ -559,13 +565,15 @@ html,body{-webkit-font-smoothing:antialiased}
|
|
|
559
565
|
.runbanner{margin-bottom:14px;padding:10px 14px;border-radius:10px;background:var(--flaky-bg);color:var(--flaky-ink);border:1px solid color-mix(in srgb,var(--flaky) 40%,transparent)}
|
|
560
566
|
.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)}
|
|
561
567
|
.badge.xfail{background:var(--flaky-bg);color:var(--flaky-ink)}
|
|
568
|
+
.xp{font-weight:500} .xp.all{color:var(--ink)} .xp.some{color:var(--flaky-ink)}
|
|
569
|
+
.badge.xp{background:var(--surface-2);border:1px solid var(--line)} .badge.xp.some{background:var(--flaky-bg);border-color:transparent}
|
|
562
570
|
.errwrap+.errwrap{margin-top:14px}
|
|
563
571
|
.why{border:1px solid var(--line);border-radius:var(--radius);padding:10px 14px;margin-bottom:10px;background:var(--surface)}
|
|
564
572
|
.why-c{font-size:12.5px;color:var(--ink);margin-bottom:2px;line-height:1.4;overflow-wrap:anywhere} .why-c .why-k{margin-right:2px}
|
|
565
573
|
.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)}
|
|
566
574
|
.why-s{font-size:var(--fs);color:var(--ink);line-height:1.45;overflow-wrap:anywhere} .why-h{font-size:12.5px;color:var(--ink-2);margin-top:4px;line-height:1.45;overflow-wrap:anywhere}
|
|
567
575
|
.why.assertion .why-k,.why.visual .why-k{color:#9A6A00}
|
|
568
|
-
.why.script .why-k,.why.thrown .why-k,.why.file .why-k{color:var(--ink-2)}
|
|
576
|
+
.why.script .why-k,.why.thrown .why-k,.why.file .why-k,.why.crashed .why-k{color:var(--ink-2)}
|
|
569
577
|
.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}
|
|
570
578
|
.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)}
|
|
571
579
|
/* ---------- triage extras: history, diff, export, env, heatmap ---------- */
|
|
@@ -765,6 +773,14 @@ const HIST = data.history||[];
|
|
|
765
773
|
const HRUNS = HIST.filter(e=>e&&e.tests); // entries that carry per-test outcomes; the last one is this run
|
|
766
774
|
const PREV = HRUNS.length>1 ? HRUNS.slice(0,-1) : []; // earlier runs only
|
|
767
775
|
const runLabel = e => e.label || new Date(e.time).toLocaleDateString(undefined,{month:'short',day:'numeric'});
|
|
776
|
+
/* Run-level trends (Trend card, KPI sparklines and "from last run", pass rate by priority) only use full-size
|
|
777
|
+
runs. A debug run of one spec or a --grep shares the history file with the full suite; a failing 1-test run
|
|
778
|
+
would read as a 0% day. A run is "small" when it ran fewer than half the median size of the recorded runs.
|
|
779
|
+
Per-test history (dots, flaky, new vs known) still uses every run: there a run only counts for the tests in it. */
|
|
780
|
+
const MED_SIZE = (()=>{ const z=HIST.map(e=>e.total||0).sort((a,b)=>a-b); return z.length? z[Math.floor(z.length/2)] : 0; })();
|
|
781
|
+
const isSmallRun = e => HIST.length>=3 && (e.total||0) < MED_SIZE/2;
|
|
782
|
+
const TREND = (()=>{ const f=HIST.filter((e,i)=>!isSmallRun(e)); return f.length>=2? f : HIST; })();
|
|
783
|
+
const SMALL_HIDDEN = HIST.length - TREND.length;
|
|
768
784
|
|
|
769
785
|
const state = { q:'', status:'all', project:'all', dims:{}, selected:null, retry:null, group:'file', open:{}, view:'overview' };
|
|
770
786
|
const DIMS = data.options.dimensions.filter(d=>data.tests.some(t=>t.meta[d]));
|
|
@@ -826,17 +842,20 @@ function failuresView(){
|
|
|
826
842
|
h('td',{class:'t'},t.file.split('/').pop()+(data.projects.length>1?' · '+t.project:'')), h('td',{},[t.meta.priority,t.meta.severity].filter(Boolean).join(' · ')), h('td',{},t.meta.owner||''), h('td',{},t.meta.story||t.meta.issue||''), h('td',{},t.results.length), PREV.length?h('td',{},(()=>{ const si=sinceInfo(t); return si? h('span',{class:'since-txt '+si.kind}, si.kind==='new'?'new':runLabel(si.since)) : h('span',{class:'t'},'–'); })()):null, HRUNS.length>1?h('td',{},dots(t,10)):null, h('td',{class:'t'},ms(t.duration))); })))));
|
|
827
843
|
return grid;
|
|
828
844
|
}
|
|
845
|
+
/** A 4xx/5xx is a failure only when the attempt that made the call failed. In a passing attempt it is the
|
|
846
|
+
* answer the test asked for (a 404 after a DELETE, a 401 for a bad token) — shown as "expected", never red. */
|
|
847
|
+
function apiCls(c,r){ const st=c.status||0; return st>=400 ? (r&&r.status==='passed'?'exp':'bad') : st>=300?'warn':'ok'; }
|
|
829
848
|
function apiView(){
|
|
830
|
-
const calls=[]; for(const t of data.tests) for(const r of t.results) for(const c of r.api) calls.push({c,t,r});
|
|
849
|
+
const calls=[]; for(const t of data.tests) for(const r of t.results) for(const c of r.api) calls.push({c,t,r,cls:apiCls(c,r)});
|
|
831
850
|
if(!calls.length) return h('div',{class:'card'},h('b',{},'No API calls recorded.'),' Add ',h('code',{},"import 'reporting-labs/auto'"),' to playwright.config.ts and every request.get / request.post / page.request call shows up here with headers, bodies and a cURL command. Calls made with other clients can be recorded with api().');
|
|
832
|
-
const bad=calls.filter(x=>(x.
|
|
833
|
-
calls.sort((a,b)=>(
|
|
851
|
+
const bad=calls.filter(x=>x.cls==='bad').length, expd=calls.filter(x=>x.cls==='exp').length, avg=Math.round(calls.reduce((a,x)=>a+(x.c.duration||0),0)/calls.length), slow=calls.filter(x=>(x.c.duration||0)>1000).length;
|
|
852
|
+
calls.sort((a,b)=>(b.cls==='bad')-(a.cls==='bad')||(b.c.duration||0)-(a.c.duration||0));
|
|
834
853
|
const byHost=new Map(); for(const x of calls){ try{ const hst=new URL(x.c.url).host; byHost.set(hst,(byHost.get(hst)||0)+1);}catch(e){} }
|
|
835
|
-
return h('div',{}, h('div',{class:'api-stats'}, h('span',{},h('b',{},calls.length),' calls'), h('span',{class:bad?'bad':''},h('b',{},bad),' failed (4xx/5xx)'), h('span',{},h('b',{},ms(avg)),' avg'), h('span',{},h('b',{},slow),' over 1s'), h('span',{},h('b',{},byHost.size),' host'+(byHost.size===1?'':'s'))),
|
|
854
|
+
return h('div',{}, h('div',{class:'api-stats'}, h('span',{},h('b',{},calls.length),' calls'), h('span',{class:bad?'bad':'',title:'4xx / 5xx responses in tests that failed'},h('b',{},bad),' failed (4xx/5xx)'), expd?h('span',{class:'exp',title:'4xx / 5xx responses in tests that passed: the answer the test expected, e.g. a 404 after a DELETE'},h('b',{},expd),' expected 4xx/5xx'):null, h('span',{},h('b',{},ms(avg)),' avg'), h('span',{},h('b',{},slow),' over 1s'), h('span',{},h('b',{},byHost.size),' host'+(byHost.size===1?'':'s'))),
|
|
836
855
|
h('div',{class:'card w12'}, h('table',{class:'apitbl'}, h('thead',{}, h('tr',{}, ['Method','URL','Status','Time','Test'].map(x=>h('th',{},x)))),
|
|
837
|
-
h('tbody',{}, calls.map(({c,t})=>{ const st=c.status||0
|
|
856
|
+
h('tbody',{}, calls.map(({c,t,cls})=>{ const st=c.status||0; return h('tr',{onclick:()=>select(t.id)},
|
|
838
857
|
h('td',{}, h('span',{class:'m '+c.method.toUpperCase(),style:'font:11px var(--mono);font-weight:600;padding:3px 6px;border-radius:3px;color:#fff;background:'+({GET:'#2A7FBF',POST:'var(--pass)',PUT:'#C77D14',PATCH:'#C77D14',DELETE:'var(--fail)'}[c.method.toUpperCase()]||'var(--ink-3)')},c.method.toUpperCase())),
|
|
839
|
-
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)); })))));
|
|
858
|
+
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)':cls==='exp'?'var(--ink-2)':'var(--pass)'),title:cls==='exp'?'The test passed: this is the response it expected':''},st||'—'), cls==='exp'?h('span',{class:'sc-exp'},'expected'):null), h('td',{class:'t'},c.duration!=null?ms(c.duration):'—'), h('td',{class:'t',title:t.title},t.title, data.projects.length>1?h('span',{class:'api-proj'},' · '+t.project):null)); })))));
|
|
840
859
|
}
|
|
841
860
|
function timelineView(){
|
|
842
861
|
const W=data.options.widgets, cards=[];
|
|
@@ -852,8 +871,9 @@ function graphsView(){
|
|
|
852
871
|
return h('div',{class:'view-pad'},
|
|
853
872
|
h('div',{class:'grid graphs-grid'},
|
|
854
873
|
chartCard('outcomes-by-priority', 'Outcomes by priority', 'Stacked pass / fail / flaky / skipped for each priority tier.', chartPriorityOutcome),
|
|
874
|
+
data.projects.length>1? chartCard('outcomes-by-project', 'Outcomes by project', 'Pass / fail / flaky / skipped and the pass rate for each project (browser, device).', chartProjectOutcome) : null,
|
|
855
875
|
chartCard('failure-categories', 'Failure categories', 'What kinds of failures are hurting the suite.', chartFailureCategories),
|
|
856
|
-
chartCard('duration-distribution','Duration distribution', 'How
|
|
876
|
+
chartCard('duration-distribution','Duration distribution', 'How the durations of the tests that ran are spread.', chartDurationHistogram),
|
|
857
877
|
chartCard('trend-by-priority', 'Pass rate trend by priority', 'How each priority tier trends over the last runs.', chartTrendByPriority),
|
|
858
878
|
chartCard('owner-leaderboard', 'Owner leaderboard', 'Failures and flakes ranked by owner. Add meta({owner:...}) to see this.', chartOwnerLeaderboard),
|
|
859
879
|
));
|
|
@@ -908,6 +928,29 @@ function chartCard(id, title, sub, chartFn){
|
|
|
908
928
|
}
|
|
909
929
|
|
|
910
930
|
/* Chart 1: horizontal stacked bar per priority (P0..P4 in order, plus 'no priority') */
|
|
931
|
+
/* Chart 1b: the same stacked bars per Playwright project (chromium, firefox, Microsoft Edge...), with each
|
|
932
|
+
project's pass rate, so a browser that fails more than the others stands out. */
|
|
933
|
+
function chartProjectOutcome(){
|
|
934
|
+
if(data.projects.length<2) return null;
|
|
935
|
+
const outs=['passed','flaky','failed','timedOut','interrupted','skipped'];
|
|
936
|
+
const colors={passed:'var(--pass)', flaky:'var(--flaky)', failed:'var(--fail)', timedOut:'var(--fail)', interrupted:'var(--fail)', skipped:'var(--skip)'};
|
|
937
|
+
const rows=data.projects.map(p=>{ const m={}; for(const o of outs) m[o]=0; for(const t of data.tests) if(t.project===p) m[t.outcome]=(m[t.outcome]||0)+1; return {p,m}; }).filter(r=>outs.some(o=>r.m[o]));
|
|
938
|
+
if(!rows.length) return null;
|
|
939
|
+
const tot=r=>outs.reduce((a,o)=>a+r.m[o],0), maxN=Math.max(...rows.map(tot));
|
|
940
|
+
const longest=Math.max(...rows.map(r=>r.p.length));
|
|
941
|
+
const W=520, rowH=28, gap=10, labW=Math.min(170,Math.max(70,longest*7+10)), barW=W-labW-96;
|
|
942
|
+
const H=rows.length*(rowH+gap)+30;
|
|
943
|
+
const parts=rows.map((r,i)=>{
|
|
944
|
+
const y=i*(rowH+gap)+18, n=tot(r), ran=n-r.m.skipped, rate=ran?Math.round(r.m.passed/ran*100):null, scale=barW/maxN;
|
|
945
|
+
let x=labW; const segs=[];
|
|
946
|
+
for(const o of outs){ const w=r.m[o]*scale; if(w<=0) continue; segs.push('<rect x="'+x+'" y="'+y+'" width="'+w+'" height="'+rowH+'" fill="'+colors[o]+'" data-outcome="'+o+'"><title>'+gEsc(r.p)+' · '+label[o]+' '+r.m[o]+'</title></rect>'); x+=w; }
|
|
947
|
+
return '<text x="0" y="'+(y+rowH/2+4)+'" fill="var(--ink)" font-size="12" font-weight="600">'+gEsc(r.p)+'</text>'+segs.join('')+
|
|
948
|
+
'<text x="'+(labW+barW+8)+'" y="'+(y+rowH/2+4)+'" fill="var(--ink-2)" font-size="12" font-variant-numeric="tabular-nums">'+n+(rate==null?'':' · <tspan fill="'+(rate===100?'var(--pass)':r.m.failed+r.m.timedOut+r.m.interrupted?'var(--fail)':'var(--ink-2)')+'" font-weight="600">'+rate+'%</tspan>')+'</text>';
|
|
949
|
+
}).join('');
|
|
950
|
+
const legend=outs.filter(o=>rows.some(r=>r.m[o]>0)).map(o=>'<rect width="10" height="10" fill="'+colors[o]+'"/><text x="14" y="9" fill="var(--ink-2)" font-size="11">'+label[o]+'</text>');
|
|
951
|
+
const legendSvg='<g transform="translate('+labW+','+(H-2)+')">'+legend.map((g,i)=>'<g transform="translate('+(i*90)+',0)">'+g+'</g>').join('')+'</g>';
|
|
952
|
+
return h('div',{class:'gchart-body',html:'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 '+W+' '+(H+18)+'" width="100%">'+parts+legendSvg+'</svg>'});
|
|
953
|
+
}
|
|
911
954
|
function chartPriorityOutcome(){
|
|
912
955
|
const order = ['P0','P1','P2','P3','P4'];
|
|
913
956
|
const outs = ['passed','flaky','failed','timedOut','interrupted','skipped'];
|
|
@@ -962,7 +1005,7 @@ function chartDurationHistogram(){
|
|
|
962
1005
|
{label:'30s+', min:30000,max:Infinity},
|
|
963
1006
|
];
|
|
964
1007
|
const counts = buckets.map(()=>0);
|
|
965
|
-
for(const t of data.tests){ const d=t.duration||0; for(let i=0;i<buckets.length;i++){ if(d>=buckets[i].min && d<buckets[i].max){ counts[i]++; break; } } }
|
|
1008
|
+
for(const t of data.tests){ if(t.outcome==='skipped') continue; const d=t.duration||0; for(let i=0;i<buckets.length;i++){ if(d>=buckets[i].min && d<buckets[i].max){ counts[i]++; break; } } }
|
|
966
1009
|
const maxN = Math.max(1,...counts);
|
|
967
1010
|
const W=520, colW=80, gap=16, padL=32, padT=24, padB=36, chartH=180;
|
|
968
1011
|
const H = chartH+padT+padB;
|
|
@@ -977,7 +1020,7 @@ function chartDurationHistogram(){
|
|
|
977
1020
|
|
|
978
1021
|
/* Chart 4: multi-line — pass rate per priority over the last N history entries */
|
|
979
1022
|
function chartTrendByPriority(){
|
|
980
|
-
const hist =
|
|
1023
|
+
const hist = TREND;
|
|
981
1024
|
if(hist.length < 2) return null;
|
|
982
1025
|
const orders = ['P0','P1','P2','P3','P4'];
|
|
983
1026
|
const seenPri = new Set();
|
|
@@ -1129,12 +1172,12 @@ function summary(){
|
|
|
1129
1172
|
const hasFail=data.tests.some(t=>isFail(t.outcome)||t.outcome==='flaky');
|
|
1130
1173
|
if(W.attention && hasFail) g.push(h('div',{class:'card w6 fail-rail'}, h('h2',{},'Needs attention', h('span',{class:'hint'},(failed+s.flaky)+' open · ranked by priority and severity')), attention(8)));
|
|
1131
1174
|
const clusters=failureClusters(); if(clusters.length) g.push(h('div',{class:'card w6 fail-rail'}, h('h2',{},'Failure clusters', h('span',{class:'hint'},clusters.length+' root cause'+(clusters.length>1?'s':'')+' · '+clusters.reduce((a,c)=>a+c.tests.length,0)+' failures')), clustersView(clusters.slice(0,6)), clusters.length>6?h('div',{class:'attn-foot'}, h('button',{onclick:()=>showView('failures')},'View all '+clusters.length+' clusters →')):null));
|
|
1132
|
-
if(W.dimensions) g.push(h('div',{class:'card w8'}, h('h2',{},'Breakdown', h('span',{class:'hint'},'click a row to filter')), breakdown()));
|
|
1175
|
+
if(W.dimensions) g.push(h('div',{class:'card w8 bd-card'}, h('h2',{},'Breakdown', h('span',{class:'hint'},'click a row to filter')), breakdown()));
|
|
1133
1176
|
if(W.slowest){ const reg=regressions().length; g.push(h('div',{class:'card'}, h('h2',{},'Slowest tests', reg?h('span',{class:'hint badge'},reg+' got slower'):null), tabbed([['slow','Slowest',slowest],['reg','Got slower'+(reg?' ('+reg+')':''),slowerView]]))); }
|
|
1134
1177
|
if(W.flaky!==false && HRUNS.length>1) g.push(h('div',{class:'card'}, h('h2',{},'Flakiest tests', h('span',{class:'hint'},'last '+Math.min(10,HRUNS.length)+' runs')), flakiest()));
|
|
1135
1178
|
if(W.skipped!==false) { const sc=skippedCard(); if(sc) g.push(sc); }
|
|
1136
1179
|
if(W.environment!==false) { const ec=envCard(); if(ec) g.push(ec); }
|
|
1137
|
-
if(data.history.length>1) g.push(h('div',{class:'card w12'}, h('h2',{},'Trend', h('span',{class:'hint'},'last '+
|
|
1180
|
+
if(data.history.length>1) g.push(h('div',{class:'card w12'}, h('h2',{},'Trend', h('span',{class:'hint'},'last '+TREND.length+' runs'+(SMALL_HIDDEN?' · '+SMALL_HIDDEN+' small run'+(SMALL_HIDDEN>1?'s':'')+' (under '+Math.ceil(MED_SIZE/2)+' tests) not plotted':''))), trend()));
|
|
1138
1181
|
else if(data.history.length===1) g.push(h('div',{class:'card w12'}, h('h2',{},'Trend', h('span',{class:'hint'},'from the second run')), h('div',{class:'nudge'}, h('b',{},'First run recorded.'),' Run the suite once more and this card shows the pass-rate trend, and every failure gets "new this run" or "failing since #…", last-10-runs dots, a Flakiest tests card and a Got slower tab. History lives in ',h('code',{},data.historyFile||'reporting-labs.history.json'),', commit it or cache it in CI.')));
|
|
1139
1182
|
const ge=data.globalErrors||[], go=data.globalOutput||[];
|
|
1140
1183
|
if(ge.length||go.some(x=>x.stream==='err')) g.unshift(h('div',{class:'card w12 fail-rail'}, h('h2',{}, ge.length? ge.length+' error'+(ge.length>1?'s':'')+' outside tests' : 'Output outside tests', h('span',{class:'hint'},'spec files that failed to load, global setup, worker crashes')),
|
|
@@ -1148,10 +1191,15 @@ function pill(key,text,n,color,sub,tone,series){
|
|
|
1148
1191
|
series? spark(series,color) : null, h('div',{class:'l'},text), h('div',{class:'v'},n), h('div',{class:'d '+(tone||'')},sub||''));
|
|
1149
1192
|
}
|
|
1150
1193
|
function initials(name){ return (name||'').split(/[\s\-–—·]+/).filter(Boolean).slice(0,2).map(w=>w[0]).join('').toUpperCase()||'R'; }
|
|
1194
|
+
/* The run this one is compared with: the latest earlier run of a comparable size (a full run is not compared
|
|
1195
|
+
with a one-spec debug run, nor a debug run with the full suite); with a build label, a different build. */
|
|
1151
1196
|
function prevRun(){
|
|
1152
1197
|
const H=data.history||[]; if(H.length<2) return null;
|
|
1153
|
-
const cur=H[H.length-1]
|
|
1154
|
-
|
|
1198
|
+
const cur=H[H.length-1], n=cur.total||0;
|
|
1199
|
+
const comparable=e=> isSmallRun(cur) ? (e.total||0)>=n/2 && (e.total||0)<=n*2 : !isSmallRun(e);
|
|
1200
|
+
let fallback=null;
|
|
1201
|
+
for(let i=H.length-2;i>=0;i--){ if(!comparable(H[i])) continue; if(!cur.label||H[i].label!==cur.label) return H[i]; if(!fallback) fallback=H[i]; }
|
|
1202
|
+
return fallback;
|
|
1155
1203
|
}
|
|
1156
1204
|
|
|
1157
1205
|
/* ---------- run history per test ---------- */
|
|
@@ -1199,7 +1247,7 @@ function slowerView(){
|
|
|
1199
1247
|
const rows=regressions().slice(0,6);
|
|
1200
1248
|
if(!rows.length) return h('div',{class:'ok'},'No test got 2× slower than last run.');
|
|
1201
1249
|
return h('ul',{class:'slow'}, rows.map(({t,prev,cur,ratio})=>h('li',{}, h('button',{onclick:()=>select(t.id)},
|
|
1202
|
-
h('span',{}, h('span',{class:'t'},t.title), h('span',{class:'bar',style:'--w:'+Math.min(100,Math.round(prev/cur*100))+'%'}, h('i',{style:'background:var(--flaky)'}))),
|
|
1250
|
+
h('span',{}, h('span',{class:'t'},t.title, data.projects.length>1?h('small',{class:'proj-sfx'},' · '+t.project):null), h('span',{class:'bar',style:'--w:'+Math.min(100,Math.round(prev/cur*100))+'%'}, h('i',{style:'background:var(--flaky)'}))),
|
|
1203
1251
|
h('span',{class:'d'}, h('b',{style:'color:var(--fail)'},'×'+(ratio>=10?Math.round(ratio):ratio.toFixed(1))), ' ', ms(prev)+' → '+ms(cur))))));
|
|
1204
1252
|
}
|
|
1205
1253
|
/* sparklines from the whole history (counts, no per-test data needed) */
|
|
@@ -1210,7 +1258,7 @@ function spark(series, color, w, h2){
|
|
|
1210
1258
|
const pts=series.map((v,i)=>x(i)+','+y(v)).join(' ');
|
|
1211
1259
|
return h('span',{class:'sp','aria-hidden':'true',html:'<svg viewBox="0 0 '+w+' '+h2+'" width="'+w+'" height="'+h2+'"><polyline points="'+pts+'" fill="none" stroke="'+color+'" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round" opacity=".9"/><circle cx="'+x(n-1)+'" cy="'+y(series[n-1])+'" r="2.2" fill="'+color+'"/></svg>'});
|
|
1212
1260
|
}
|
|
1213
|
-
function hseries(f){ return
|
|
1261
|
+
function hseries(f){ return TREND.slice(-12).map(f); }
|
|
1214
1262
|
/* owner rollup for the Failures view and the copied summary */
|
|
1215
1263
|
function byOwner(){
|
|
1216
1264
|
const m=new Map();
|
|
@@ -1348,13 +1396,28 @@ function donut(){
|
|
|
1348
1396
|
const svg=h('div',{html:'<svg viewBox="0 0 132 132">'+arcs+'<text class="c" x="66" y="64" text-anchor="middle">'+rate+'%</text><text class="cl" x="66" y="80" text-anchor="middle">pass rate</text></svg>'}).firstChild;
|
|
1349
1397
|
return h('div',{class:'donut'}, svg, h('div',{class:'legend'}, parts.map(([k,n])=>h('button',{onclick:()=>{state.status=k;refresh();showView('tests');}}, h('span',{class:'dot',style:'background:'+colorOf(k)}), h('span',{},label[k]), h('span',{class:'n'},n)))));
|
|
1350
1398
|
}
|
|
1399
|
+
/* Cross-project signal. The same test (file, describe path, title) runs once per project — a browser, a
|
|
1400
|
+
device. Failing in every project it ran in points at the app or the test itself; failing in some projects
|
|
1401
|
+
only points at those browsers. Skipped runs do not count; a flaky pass counts as a pass. */
|
|
1402
|
+
var XP=null; // var: the views are built before this line runs
|
|
1403
|
+
function xpKey(t){ return t.file+'::'+t.path.join('/')+'::'+t.title; }
|
|
1404
|
+
function xproj(t){
|
|
1405
|
+
if(data.projects.length<2) return null;
|
|
1406
|
+
if(!XP){ XP=new Map(); for(const u of data.tests){ if(u.outcome==='skipped') continue; const g=XP.get(xpKey(u))||[]; g.push(u); XP.set(xpKey(u),g); } }
|
|
1407
|
+
const g=XP.get(xpKey(t))||[]; if(g.length<2) return null;
|
|
1408
|
+
const bad=g.filter(u=>isFail(u.outcome)); if(!bad.length) return null;
|
|
1409
|
+
if(bad.length===g.length) return {all:true, text:g.length===2?'fails on both projects':'fails on all '+g.length+' projects', tip:'The same failure in every project it ran in: look at the app or the test, not the browser'};
|
|
1410
|
+
const names=bad.map(u=>u.project), good=g.filter(u=>!isFail(u.outcome)).map(u=>u.project);
|
|
1411
|
+
return {all:false, text: names.length<=2? 'only on '+names.join(' and ') : 'on '+names.length+' of '+g.length+' projects', tip:'Passes on '+good.join(', ')+': specific to '+names.join(', ')};
|
|
1412
|
+
}
|
|
1413
|
+
function xpTag(xp,cls){ return h('span',{class:'xp '+(xp.all?'all':'some')+(cls?' '+cls:''),title:xp.tip},xp.text); }
|
|
1351
1414
|
function attention(limit){
|
|
1352
1415
|
const bad=data.tests.filter(t=>isFail(t.outcome)||t.outcome==='flaky').sort((a,b)=>rank(a)-rank(b)||(isFail(b.outcome)-isFail(a.outcome))||b.duration-a.duration);
|
|
1353
1416
|
if(!bad.length) return h('div',{class:'ok'},'No failures. Ship it.');
|
|
1354
1417
|
const groups=new Map(); for(const t of bad){ const k=t.file+'::'+t.path.join('/')+'::'+t.title; const g=groups.get(k)||{t,projects:[]}; g.projects.push(t.project); groups.set(k,g); }
|
|
1355
1418
|
const all=[...groups.values()], rows=limit?all.slice(0,limit):all;
|
|
1356
1419
|
const sub=(t,projects)=>{ const parts=[t.file.split('/').pop()];
|
|
1357
|
-
if(data.projects.length>1) parts.push(projects.length>1? projects.length+' projects' : projects[0]);
|
|
1420
|
+
if(data.projects.length>1){ const xp=xproj(t); parts.push(xp? xpTag(xp) : projects.length>1? projects.length+' projects' : projects[0]); }
|
|
1358
1421
|
const si=sinceInfo(t); if(si) parts.push(si.kind==='new'? h('b',{class:'new'},'new this run') : 'failing since '+runLabel(si.since));
|
|
1359
1422
|
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));
|
|
1360
1423
|
else if(t.outcome==='flaky') parts.push('passed on retry '+t.results.length);
|
|
@@ -1438,7 +1501,7 @@ function clusterLine(c){
|
|
|
1438
1501
|
// in place and "show fewer" folds it again. Each name opens that test.
|
|
1439
1502
|
function clusterWho(byTitle, all){
|
|
1440
1503
|
const entries=[...byTitle.entries()], names=all?entries:entries.slice(0,6), extra=entries.length-names.length;
|
|
1441
|
-
const who=names.map(([title,ts])=>h('button',{onclick:()=>select(ts[0].id),title:ts.map(t=>t.project).join(', ')}, h('span',{class:'tri'},'›'), title, data.projects.length>1&&ts.length>1? h('small',{},' ×'+ts.length) : null));
|
|
1504
|
+
const who=names.map(([title,ts])=>h('button',{onclick:()=>select(ts[0].id),title:ts.map(t=>t.project).join(', ')}, h('span',{class:'tri'},'›'), title, data.projects.length>1&&ts.length>1? h('small',{},' ×'+ts.length) : null, (()=>{ const xp=xproj(ts[0]); return xp? h('small',{class:'xp '+(xp.all?'all':'some'),title:xp.tip},' · '+xp.text) : null; })()));
|
|
1442
1505
|
const swap=(ev,toAll)=>{ const d=ev.currentTarget.closest('.who'); d.replaceChildren(); for(const n of clusterWho(byTitle,toAll)) d.append(n); };
|
|
1443
1506
|
if(extra>0) who.push(h('button',{class:'ex',onclick:ev=>swap(ev,true),title:'Show all '+entries.length+' tests'},'+'+extra+' more'));
|
|
1444
1507
|
else if(all&&entries.length>6) who.push(h('button',{class:'ex',onclick:ev=>swap(ev,false)},'show fewer'));
|
|
@@ -1452,7 +1515,7 @@ function clustersView(cl){
|
|
|
1452
1515
|
}));
|
|
1453
1516
|
}
|
|
1454
1517
|
function trend(){
|
|
1455
|
-
const H=
|
|
1518
|
+
const H=TREND, W=760, HT=170, padL=34, padR=12, padT=12, padB=26, n=H.length;
|
|
1456
1519
|
const x=i=>padL+(n===1?0:i/(n-1))*(W-padL-padR), y=v=>padT+(1-v/100)*(HT-padT-padB);
|
|
1457
1520
|
const rate=e=>{ const ran=e.total-e.skipped; return ran?Math.round(e.passed/ran*100):0; };
|
|
1458
1521
|
const failPct=e=>{ const ran=e.total-e.skipped; return ran?Math.round(e.failed/ran*100):0; };
|
|
@@ -1462,9 +1525,14 @@ function trend(){
|
|
|
1462
1525
|
svg+='<path class="area" d="M'+x(0)+','+y(0)+' '+H.map((e,i)=>'L'+x(i)+','+y(rate(e))).join(' ')+' L'+x(n-1)+','+y(0)+' Z"/>';
|
|
1463
1526
|
svg+='<polyline class="fail" points="'+H.map((e,i)=>x(i)+','+y(failPct(e))).join(' ')+'"/>';
|
|
1464
1527
|
svg+='<polyline class="pass" points="'+H.map((e,i)=>x(i)+','+y(rate(e))).join(' ')+'"/>';
|
|
1465
|
-
svg+=H.map((e,i)=>'<circle class="pt'+(i===n-1?' now':'')+'" cx="'+x(i)+'" cy="'+y(rate(e))+'" r="3.5"><title>'+new Date(e.time).toLocaleString()+(e.label?' · '+e.label:'')+' · '+rate(e)+'% pass · '+e.failed+' failed · '+ms(e.duration)+'</title></circle>').join('');
|
|
1466
|
-
|
|
1467
|
-
|
|
1528
|
+
svg+=H.map((e,i)=>'<circle class="pt'+(i===n-1&&e===HIST[HIST.length-1]?' now':'')+'" cx="'+x(i)+'" cy="'+y(rate(e))+'" r="3.5"><title>'+new Date(e.time).toLocaleString()+(e.label?' · '+e.label:'')+' · '+rate(e)+'% pass · '+e.failed+' failed · '+ms(e.duration)+'</title></circle>').join('');
|
|
1529
|
+
// Several runs on one day get the time too; longer labels mean fewer of them, so they never overlap
|
|
1530
|
+
const day=e=>new Date(e.time).toLocaleDateString(undefined,{month:'short',day:'numeric'});
|
|
1531
|
+
const days=H.filter(e=>!e.label).map(day), twice=days.length!==new Set(days).size;
|
|
1532
|
+
const step=Math.max(1,Math.ceil(n/(twice?5:8)));
|
|
1533
|
+
const shown=H.map((e,i)=>i===n-1||(i%step===0&&n-1-i>=step));
|
|
1534
|
+
const axis=e=> e.label || (twice ? day(e)+' '+new Date(e.time).toLocaleTimeString(undefined,{hour:'numeric',minute:'2-digit'}) : day(e));
|
|
1535
|
+
svg+=H.map((e,i)=>shown[i]?'<text x="'+x(i)+'" y="'+(HT-6)+'" text-anchor="'+(i===n-1&&n>1?'end':i===0&&twice?'start':'middle')+'">'+escape(axis(e))+'</text>':'').join('');
|
|
1468
1536
|
const colW=n>1?(x(1)-x(0)):(W-padL-padR);
|
|
1469
1537
|
svg+='<line class="guide" x1="0" x2="0" y1="'+padT+'" y2="'+y(0)+'" style="display:none"/>';
|
|
1470
1538
|
svg+=H.map((e,i)=>'<rect class="hit" data-i="'+i+'" x="'+(x(i)-colW/2)+'" y="0" width="'+colW+'" height="'+HT+'" fill="transparent"/>').join('');
|
|
@@ -1489,7 +1557,7 @@ function trend(){
|
|
|
1489
1557
|
svgEl.addEventListener('mousemove',ev=>{ const r=ev.target.closest&&ev.target.closest('.hit'); if(!r){ tip.hidden=true; guide.style.display='none'; return; } show(+r.dataset.i,ev); });
|
|
1490
1558
|
svgEl.addEventListener('mouseleave',()=>{ tip.hidden=true; guide.style.display='none'; });
|
|
1491
1559
|
wrap.append(
|
|
1492
|
-
h('div',{class:'trend-cap'}, h('span',{style:'color:var(--pass)'},'— pass rate'), h('span',{style:'color:var(--fail)'},'- - fail rate'), h('span',{style:'color:var(--accent)'},'▮ duration'), h('span',{style:'margin-left:auto'}, 'this run: '+rate(
|
|
1560
|
+
h('div',{class:'trend-cap'}, h('span',{style:'color:var(--pass)'},'— pass rate'), h('span',{style:'color:var(--fail)'},'- - fail rate'), h('span',{style:'color:var(--accent)'},'▮ duration'), h('span',{style:'margin-left:auto'}, 'this run: '+rate(HIST[HIST.length-1])+'% · '+ms(HIST[HIST.length-1].duration)+(isSmallRun(HIST[HIST.length-1])?' · small run, not plotted':''))));
|
|
1493
1561
|
return wrap;
|
|
1494
1562
|
}
|
|
1495
1563
|
function workers(){
|
|
@@ -1528,7 +1596,7 @@ function slowest(){
|
|
|
1528
1596
|
const top=[...data.tests].filter(t=>t.outcome!=='skipped').sort((a,b)=>b.duration-a.duration).slice(0,5);
|
|
1529
1597
|
const max=top[0]?top[0].duration:1;
|
|
1530
1598
|
return h('ul',{class:'slow'}, top.map(t=>h('li',{}, h('button',{onclick:()=>select(t.id)},
|
|
1531
|
-
h('span',{}, h('span',{class:'t'},t.title), h('span',{class:'bar',style:'--w:'+Math.max(4,Math.round(t.duration/max*100))+'%'}, h('i',{}))), h('span',{class:'d'},ms(t.duration))))));
|
|
1599
|
+
h('span',{}, h('span',{class:'t'},t.title, data.projects.length>1?h('small',{class:'proj-sfx'},' · '+t.project):null), h('span',{class:'bar',style:'--w:'+Math.max(4,Math.round(t.duration/max*100))+'%'}, h('i',{}))), h('span',{class:'d'},ms(t.duration))))));
|
|
1532
1600
|
}
|
|
1533
1601
|
function projects(){
|
|
1534
1602
|
return h('div',{class:'proj'}, data.projects.map(p=>{
|
|
@@ -1632,7 +1700,7 @@ function renderDetail(t, d, retryIdx){
|
|
|
1632
1700
|
h('button',{class:'btn',onclick:e=>copyText(location.href.split('#')[0]+'#t='+t.id,e.currentTarget,'Link copied')},'Copy link'),
|
|
1633
1701
|
(()=>{ 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; })(),
|
|
1634
1702
|
editorLink(t))),
|
|
1635
|
-
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:''))));
|
|
1703
|
+
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, (()=>{ const xp=isFail(t.outcome)&&xproj(t); return xp? xpTag(xp,'badge') : 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:''))));
|
|
1636
1704
|
const metaKeys=Object.keys(t.meta);
|
|
1637
1705
|
const linkFor=(k,v)=>{ if(t.links&&t.links[k]) return t.links[k]; const tpl=data.options.links[k]||data.options.links['*']; if(tpl) return tpl.replace('{id}',encodeURIComponent(v)); if(/^https?:\/\//.test(v)) return v; return null; };
|
|
1638
1706
|
if(metaKeys.length) d.append(h('div',{class:'metas'}, metaKeys.map(k=>{ const v=t.meta[k], low=/^(P[3-4]|low|minor|trivial|normal|medium)$/i.test(v); const parts=(t.links&&t.links[k])||!data.options.links[k]&&!data.options.links['*']?[v]:v.split(/,\s+/); const nodes=[]; parts.forEach((part,i)=>{ const href=linkFor(k,part); if(i) nodes.push(', '); nodes.push(href? h('a',{href,target:'_blank',rel:'noopener'},part) : h('span',{class:'v'},part)); }); return h('span',{class:'meta '+k+(low?' low':'')}, h('span',{class:'k'},k), ...nodes); })));
|
|
@@ -1648,7 +1716,7 @@ function renderDetail(t, d, retryIdx){
|
|
|
1648
1716
|
if(r.steps.length){ const stepTotal=Math.max(1, r.duration||0, r.steps.reduce((a,x)=>a+x.duration,0)); body.append(h('h4',{},data.bdd?'Scenario steps':'Steps', h('span',{class:'hint'},'bar = share of '+ms(stepTotal))), stepTree(r.steps, stepTotal)); }
|
|
1649
1717
|
if(r.logs.length){ const t0=r.startTime; body.append(h('h4',{},'Log'), h('div',{class:'logs'}, r.logs.map(l=>h('div',{class:'ln'+(/\b(error|fail|exception)\b/i.test(l.msg)?' err':/\bwarn/i.test(l.msg)?' warn':'')}, h('span',{class:'ts'},'+'+ms(Math.max(0,l.t-t0))), h('span',{class:'lm'},l.msg))))); }
|
|
1650
1718
|
for(const b of r.data){ body.append(h('h4',{},b.name), dataBlock(b)); }
|
|
1651
|
-
if(r.api.length){ body.append(h('h4',{},r.api.length+' API call'+(r.api.length>1?'s':'')), ...r.api.map(apiPanel)); body.append(h('div',{class:'mask-note'},'Secrets and auth headers are masked as ****')); }
|
|
1719
|
+
if(r.api.length){ body.append(h('h4',{},r.api.length+' API call'+(r.api.length>1?'s':'')), ...r.api.map(c=>apiPanel(c,r))); body.append(h('div',{class:'mask-note'},'Secrets and auth headers are masked as ****')); }
|
|
1652
1720
|
const imgs=r.attachments.filter(a=>a.src&&a.contentType.startsWith('image/'));
|
|
1653
1721
|
const vids=r.attachments.filter(a=>a.src&&a.contentType.startsWith('video/'));
|
|
1654
1722
|
const traces=r.attachments.filter(a=>a.src&&(a.name==='trace'||/\.zip$/.test(a.src)));
|
|
@@ -1693,10 +1761,10 @@ function dataBlock(b){
|
|
|
1693
1761
|
if(b.kind==='kv') return h('dl',{class:'kv2'}, b.kv.map(([k,v])=>[h('dt',{},k),h('dd',{class:v==='****'?'mask':''},v)]));
|
|
1694
1762
|
return h('pre',{class:'txt'},b.text);
|
|
1695
1763
|
}
|
|
1696
|
-
function apiPanel(c){
|
|
1697
|
-
const st=c.status||0, cls=
|
|
1764
|
+
function apiPanel(c,r){
|
|
1765
|
+
const st=c.status||0, cls=apiCls(c,r);
|
|
1698
1766
|
const wrap=h('div',{class:'api collapsed'});
|
|
1699
|
-
const head=h('button',{class:'api-head',onclick:()=>wrap.classList.toggle('collapsed')}, h('span',{class:'m '+c.method.toUpperCase()},c.method.toUpperCase()), h('span',{class:'u',title:c.url},c.url), st?h('span',{class:'sc '+cls},st):null, c.duration!=null?h('span',{class:'d'},ms(c.duration)):null);
|
|
1767
|
+
const head=h('button',{class:'api-head',onclick:()=>wrap.classList.toggle('collapsed')}, h('span',{class:'m '+c.method.toUpperCase()},c.method.toUpperCase()), h('span',{class:'u',title:c.url},c.url), st?h('span',{class:'sc '+cls,title:cls==='exp'?'The test passed: this is the response it expected':''},st):null, cls==='exp'?h('span',{class:'sc-exp'},'expected'):null, c.duration!=null?h('span',{class:'d'},ms(c.duration)):null);
|
|
1700
1768
|
const pre=v=>v==null?null:h('pre',{}, typeof v==='string'?v:JSON.stringify(v,null,2));
|
|
1701
1769
|
const col=(title,headers,bodyv)=>h('div',{class:'api-col'}, h('h5',{},title), headers&&Object.keys(headers).length? [h('h5',{},'Headers'), pre(headers)] : null, bodyv!=null? [h('h5',{},'Body'), pre(bodyv)] : h('div',{style:'font-size:12px;color:var(--ink-3)'},'no body'));
|
|
1702
1770
|
const tools=h('div',{class:'api-tools'}, h('button',{class:'btn',onclick:e=>copyText(curlOf(c),e.currentTarget,'Copied')},'Copy as cURL'), h('button',{class:'btn',onclick:e=>copyText(c.url,e.currentTarget,'Copied')},'Copy URL'));
|
package/dist/template.js
CHANGED
|
@@ -415,6 +415,10 @@ li.collapsed>ul{display:none}
|
|
|
415
415
|
.wk .row{display:grid;grid-template-columns:36px 1fr 120px;gap:10px;align-items:center;font-size:12px}
|
|
416
416
|
.wk .k{font:12px var(--mono);color:var(--ink-3)} .wk .bar{height:12px;display:flex;border-radius:3px;overflow:hidden;background:var(--skip-bg)} .wk .bar i{display:block;height:100%}
|
|
417
417
|
.wk .n{font:11px var(--mono);color:var(--ink-3);text-align:right}
|
|
418
|
+
.wk .row{grid-template-columns:36px 1fr auto} .wk .n{white-space:nowrap}
|
|
419
|
+
.proj-sfx{font-weight:400;color:var(--ink-3);font-size:.9em}
|
|
420
|
+
/* the Breakdown card keeps its own height: a two-priority suite should not leave a tall empty card */
|
|
421
|
+
.bd-card{align-self:start}
|
|
418
422
|
.wk-sum{font-size:13px;color:var(--ink-2);margin-bottom:10px}
|
|
419
423
|
/* list: group toggle + folder tree */
|
|
420
424
|
.tools .seg,.bug-tools .seg{display:inline-flex;border:1px solid var(--line);border-radius:var(--radius);overflow:hidden}
|
|
@@ -450,7 +454,9 @@ table.tbl{border-collapse:collapse;font-size:12.5px;width:100%;min-width:100%}
|
|
|
450
454
|
.api-head .m{font:11px/1 var(--mono);font-weight:600;padding:4px 7px;border-radius:3px;color:#fff;background:var(--ink-3)}
|
|
451
455
|
.api-head .m.GET{background:#2A7FBF} .api-head .m.POST{background:var(--pass)} .api-head .m.PUT,.api-head .m.PATCH{background:#C77D14} .api-head .m.DELETE{background:var(--fail)}
|
|
452
456
|
.api-head .u{flex:1;font:12px var(--mono);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
453
|
-
.api-head .sc{font:12px var(--mono);font-weight:600} .api-head .sc.ok{color:var(--pass)} .api-head .sc.bad{color:var(--fail)} .api-head .sc.warn{color:#9A6A00}
|
|
457
|
+
.api-head .sc{font:12px var(--mono);font-weight:600} .api-head .sc.ok{color:var(--pass)} .api-head .sc.bad{color:var(--fail)} .api-head .sc.warn{color:#9A6A00} .api-head .sc.exp{color:var(--ink-2)}
|
|
458
|
+
.sc-exp{margin-left:6px;font:500 10.5px var(--sans);color:var(--ink-3);background:var(--surface-2);border:1px solid var(--line);border-radius:999px;padding:1px 7px;vertical-align:1px}
|
|
459
|
+
.api-stats .exp b{color:var(--ink-2)} .api-proj{color:var(--ink-3)}
|
|
454
460
|
.api-head .d{font:11px var(--mono);color:var(--ink-3)}
|
|
455
461
|
.api-body{display:grid;grid-template-columns:1fr 1fr;gap:0}
|
|
456
462
|
@media (max-width:900px){.api-body{grid-template-columns:1fr}}
|
|
@@ -630,13 +636,15 @@ html,body{-webkit-font-smoothing:antialiased}
|
|
|
630
636
|
.runbanner{margin-bottom:14px;padding:10px 14px;border-radius:10px;background:var(--flaky-bg);color:var(--flaky-ink);border:1px solid color-mix(in srgb,var(--flaky) 40%,transparent)}
|
|
631
637
|
.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)}
|
|
632
638
|
.badge.xfail{background:var(--flaky-bg);color:var(--flaky-ink)}
|
|
639
|
+
.xp{font-weight:500} .xp.all{color:var(--ink)} .xp.some{color:var(--flaky-ink)}
|
|
640
|
+
.badge.xp{background:var(--surface-2);border:1px solid var(--line)} .badge.xp.some{background:var(--flaky-bg);border-color:transparent}
|
|
633
641
|
.errwrap+.errwrap{margin-top:14px}
|
|
634
642
|
.why{border:1px solid var(--line);border-radius:var(--radius);padding:10px 14px;margin-bottom:10px;background:var(--surface)}
|
|
635
643
|
.why-c{font-size:12.5px;color:var(--ink);margin-bottom:2px;line-height:1.4;overflow-wrap:anywhere} .why-c .why-k{margin-right:2px}
|
|
636
644
|
.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)}
|
|
637
645
|
.why-s{font-size:var(--fs);color:var(--ink);line-height:1.45;overflow-wrap:anywhere} .why-h{font-size:12.5px;color:var(--ink-2);margin-top:4px;line-height:1.45;overflow-wrap:anywhere}
|
|
638
646
|
.why.assertion .why-k,.why.visual .why-k{color:#9A6A00}
|
|
639
|
-
.why.script .why-k,.why.thrown .why-k,.why.file .why-k{color:var(--ink-2)}
|
|
647
|
+
.why.script .why-k,.why.thrown .why-k,.why.file .why-k,.why.crashed .why-k{color:var(--ink-2)}
|
|
640
648
|
.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}
|
|
641
649
|
.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)}
|
|
642
650
|
/* ---------- triage extras: history, diff, export, env, heatmap ---------- */
|
|
@@ -831,6 +839,14 @@ const HIST = data.history||[];
|
|
|
831
839
|
const HRUNS = HIST.filter(e=>e&&e.tests); // entries that carry per-test outcomes; the last one is this run
|
|
832
840
|
const PREV = HRUNS.length>1 ? HRUNS.slice(0,-1) : []; // earlier runs only
|
|
833
841
|
const runLabel = e => e.label || new Date(e.time).toLocaleDateString(undefined,{month:'short',day:'numeric'});
|
|
842
|
+
/* Run-level trends (Trend card, KPI sparklines and "from last run", pass rate by priority) only use full-size
|
|
843
|
+
runs. A debug run of one spec or a --grep shares the history file with the full suite; a failing 1-test run
|
|
844
|
+
would read as a 0% day. A run is "small" when it ran fewer than half the median size of the recorded runs.
|
|
845
|
+
Per-test history (dots, flaky, new vs known) still uses every run: there a run only counts for the tests in it. */
|
|
846
|
+
const MED_SIZE = (()=>{ const z=HIST.map(e=>e.total||0).sort((a,b)=>a-b); return z.length? z[Math.floor(z.length/2)] : 0; })();
|
|
847
|
+
const isSmallRun = e => HIST.length>=3 && (e.total||0) < MED_SIZE/2;
|
|
848
|
+
const TREND = (()=>{ const f=HIST.filter((e,i)=>!isSmallRun(e)); return f.length>=2? f : HIST; })();
|
|
849
|
+
const SMALL_HIDDEN = HIST.length - TREND.length;
|
|
834
850
|
|
|
835
851
|
const state = { q:'', status:'all', project:'all', dims:{}, selected:null, retry:null, group:'file', open:{}, view:'overview' };
|
|
836
852
|
const DIMS = data.options.dimensions.filter(d=>data.tests.some(t=>t.meta[d]));
|
|
@@ -892,17 +908,20 @@ function failuresView(){
|
|
|
892
908
|
h('td',{class:'t'},t.file.split('/').pop()+(data.projects.length>1?' · '+t.project:'')), h('td',{},[t.meta.priority,t.meta.severity].filter(Boolean).join(' · ')), h('td',{},t.meta.owner||''), h('td',{},t.meta.story||t.meta.issue||''), h('td',{},t.results.length), PREV.length?h('td',{},(()=>{ const si=sinceInfo(t); return si? h('span',{class:'since-txt '+si.kind}, si.kind==='new'?'new':runLabel(si.since)) : h('span',{class:'t'},'–'); })()):null, HRUNS.length>1?h('td',{},dots(t,10)):null, h('td',{class:'t'},ms(t.duration))); })))));
|
|
893
909
|
return grid;
|
|
894
910
|
}
|
|
911
|
+
/** A 4xx/5xx is a failure only when the attempt that made the call failed. In a passing attempt it is the
|
|
912
|
+
* answer the test asked for (a 404 after a DELETE, a 401 for a bad token) — shown as "expected", never red. */
|
|
913
|
+
function apiCls(c,r){ const st=c.status||0; return st>=400 ? (r&&r.status==='passed'?'exp':'bad') : st>=300?'warn':'ok'; }
|
|
895
914
|
function apiView(){
|
|
896
|
-
const calls=[]; for(const t of data.tests) for(const r of t.results) for(const c of r.api) calls.push({c,t,r});
|
|
915
|
+
const calls=[]; for(const t of data.tests) for(const r of t.results) for(const c of r.api) calls.push({c,t,r,cls:apiCls(c,r)});
|
|
897
916
|
if(!calls.length) return h('div',{class:'card'},h('b',{},'No API calls recorded.'),' Add ',h('code',{},"import 'reporting-labs/auto'"),' to playwright.config.ts and every request.get / request.post / page.request call shows up here with headers, bodies and a cURL command. Calls made with other clients can be recorded with api().');
|
|
898
|
-
const bad=calls.filter(x=>(x.
|
|
899
|
-
calls.sort((a,b)=>(
|
|
917
|
+
const bad=calls.filter(x=>x.cls==='bad').length, expd=calls.filter(x=>x.cls==='exp').length, avg=Math.round(calls.reduce((a,x)=>a+(x.c.duration||0),0)/calls.length), slow=calls.filter(x=>(x.c.duration||0)>1000).length;
|
|
918
|
+
calls.sort((a,b)=>(b.cls==='bad')-(a.cls==='bad')||(b.c.duration||0)-(a.c.duration||0));
|
|
900
919
|
const byHost=new Map(); for(const x of calls){ try{ const hst=new URL(x.c.url).host; byHost.set(hst,(byHost.get(hst)||0)+1);}catch(e){} }
|
|
901
|
-
return h('div',{}, h('div',{class:'api-stats'}, h('span',{},h('b',{},calls.length),' calls'), h('span',{class:bad?'bad':''},h('b',{},bad),' failed (4xx/5xx)'), h('span',{},h('b',{},ms(avg)),' avg'), h('span',{},h('b',{},slow),' over 1s'), h('span',{},h('b',{},byHost.size),' host'+(byHost.size===1?'':'s'))),
|
|
920
|
+
return h('div',{}, h('div',{class:'api-stats'}, h('span',{},h('b',{},calls.length),' calls'), h('span',{class:bad?'bad':'',title:'4xx / 5xx responses in tests that failed'},h('b',{},bad),' failed (4xx/5xx)'), expd?h('span',{class:'exp',title:'4xx / 5xx responses in tests that passed: the answer the test expected, e.g. a 404 after a DELETE'},h('b',{},expd),' expected 4xx/5xx'):null, h('span',{},h('b',{},ms(avg)),' avg'), h('span',{},h('b',{},slow),' over 1s'), h('span',{},h('b',{},byHost.size),' host'+(byHost.size===1?'':'s'))),
|
|
902
921
|
h('div',{class:'card w12'}, h('table',{class:'apitbl'}, h('thead',{}, h('tr',{}, ['Method','URL','Status','Time','Test'].map(x=>h('th',{},x)))),
|
|
903
|
-
h('tbody',{}, calls.map(({c,t})=>{ const st=c.status||0
|
|
922
|
+
h('tbody',{}, calls.map(({c,t,cls})=>{ const st=c.status||0; return h('tr',{onclick:()=>select(t.id)},
|
|
904
923
|
h('td',{}, h('span',{class:'m '+c.method.toUpperCase(),style:'font:11px var(--mono);font-weight:600;padding:3px 6px;border-radius:3px;color:#fff;background:'+({GET:'#2A7FBF',POST:'var(--pass)',PUT:'#C77D14',PATCH:'#C77D14',DELETE:'var(--fail)'}[c.method.toUpperCase()]||'var(--ink-3)')},c.method.toUpperCase())),
|
|
905
|
-
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)); })))));
|
|
924
|
+
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)':cls==='exp'?'var(--ink-2)':'var(--pass)'),title:cls==='exp'?'The test passed: this is the response it expected':''},st||'—'), cls==='exp'?h('span',{class:'sc-exp'},'expected'):null), h('td',{class:'t'},c.duration!=null?ms(c.duration):'—'), h('td',{class:'t',title:t.title},t.title, data.projects.length>1?h('span',{class:'api-proj'},' · '+t.project):null)); })))));
|
|
906
925
|
}
|
|
907
926
|
function timelineView(){
|
|
908
927
|
const W=data.options.widgets, cards=[];
|
|
@@ -918,8 +937,9 @@ function graphsView(){
|
|
|
918
937
|
return h('div',{class:'view-pad'},
|
|
919
938
|
h('div',{class:'grid graphs-grid'},
|
|
920
939
|
chartCard('outcomes-by-priority', 'Outcomes by priority', 'Stacked pass / fail / flaky / skipped for each priority tier.', chartPriorityOutcome),
|
|
940
|
+
data.projects.length>1? chartCard('outcomes-by-project', 'Outcomes by project', 'Pass / fail / flaky / skipped and the pass rate for each project (browser, device).', chartProjectOutcome) : null,
|
|
921
941
|
chartCard('failure-categories', 'Failure categories', 'What kinds of failures are hurting the suite.', chartFailureCategories),
|
|
922
|
-
chartCard('duration-distribution','Duration distribution', 'How
|
|
942
|
+
chartCard('duration-distribution','Duration distribution', 'How the durations of the tests that ran are spread.', chartDurationHistogram),
|
|
923
943
|
chartCard('trend-by-priority', 'Pass rate trend by priority', 'How each priority tier trends over the last runs.', chartTrendByPriority),
|
|
924
944
|
chartCard('owner-leaderboard', 'Owner leaderboard', 'Failures and flakes ranked by owner. Add meta({owner:...}) to see this.', chartOwnerLeaderboard),
|
|
925
945
|
));
|
|
@@ -974,6 +994,29 @@ function chartCard(id, title, sub, chartFn){
|
|
|
974
994
|
}
|
|
975
995
|
|
|
976
996
|
/* Chart 1: horizontal stacked bar per priority (P0..P4 in order, plus 'no priority') */
|
|
997
|
+
/* Chart 1b: the same stacked bars per Playwright project (chromium, firefox, Microsoft Edge...), with each
|
|
998
|
+
project's pass rate, so a browser that fails more than the others stands out. */
|
|
999
|
+
function chartProjectOutcome(){
|
|
1000
|
+
if(data.projects.length<2) return null;
|
|
1001
|
+
const outs=['passed','flaky','failed','timedOut','interrupted','skipped'];
|
|
1002
|
+
const colors={passed:'var(--pass)', flaky:'var(--flaky)', failed:'var(--fail)', timedOut:'var(--fail)', interrupted:'var(--fail)', skipped:'var(--skip)'};
|
|
1003
|
+
const rows=data.projects.map(p=>{ const m={}; for(const o of outs) m[o]=0; for(const t of data.tests) if(t.project===p) m[t.outcome]=(m[t.outcome]||0)+1; return {p,m}; }).filter(r=>outs.some(o=>r.m[o]));
|
|
1004
|
+
if(!rows.length) return null;
|
|
1005
|
+
const tot=r=>outs.reduce((a,o)=>a+r.m[o],0), maxN=Math.max(...rows.map(tot));
|
|
1006
|
+
const longest=Math.max(...rows.map(r=>r.p.length));
|
|
1007
|
+
const W=520, rowH=28, gap=10, labW=Math.min(170,Math.max(70,longest*7+10)), barW=W-labW-96;
|
|
1008
|
+
const H=rows.length*(rowH+gap)+30;
|
|
1009
|
+
const parts=rows.map((r,i)=>{
|
|
1010
|
+
const y=i*(rowH+gap)+18, n=tot(r), ran=n-r.m.skipped, rate=ran?Math.round(r.m.passed/ran*100):null, scale=barW/maxN;
|
|
1011
|
+
let x=labW; const segs=[];
|
|
1012
|
+
for(const o of outs){ const w=r.m[o]*scale; if(w<=0) continue; segs.push('<rect x="'+x+'" y="'+y+'" width="'+w+'" height="'+rowH+'" fill="'+colors[o]+'" data-outcome="'+o+'"><title>'+gEsc(r.p)+' · '+label[o]+' '+r.m[o]+'</title></rect>'); x+=w; }
|
|
1013
|
+
return '<text x="0" y="'+(y+rowH/2+4)+'" fill="var(--ink)" font-size="12" font-weight="600">'+gEsc(r.p)+'</text>'+segs.join('')+
|
|
1014
|
+
'<text x="'+(labW+barW+8)+'" y="'+(y+rowH/2+4)+'" fill="var(--ink-2)" font-size="12" font-variant-numeric="tabular-nums">'+n+(rate==null?'':' · <tspan fill="'+(rate===100?'var(--pass)':r.m.failed+r.m.timedOut+r.m.interrupted?'var(--fail)':'var(--ink-2)')+'" font-weight="600">'+rate+'%</tspan>')+'</text>';
|
|
1015
|
+
}).join('');
|
|
1016
|
+
const legend=outs.filter(o=>rows.some(r=>r.m[o]>0)).map(o=>'<rect width="10" height="10" fill="'+colors[o]+'"/><text x="14" y="9" fill="var(--ink-2)" font-size="11">'+label[o]+'</text>');
|
|
1017
|
+
const legendSvg='<g transform="translate('+labW+','+(H-2)+')">'+legend.map((g,i)=>'<g transform="translate('+(i*90)+',0)">'+g+'</g>').join('')+'</g>';
|
|
1018
|
+
return h('div',{class:'gchart-body',html:'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 '+W+' '+(H+18)+'" width="100%">'+parts+legendSvg+'</svg>'});
|
|
1019
|
+
}
|
|
977
1020
|
function chartPriorityOutcome(){
|
|
978
1021
|
const order = ['P0','P1','P2','P3','P4'];
|
|
979
1022
|
const outs = ['passed','flaky','failed','timedOut','interrupted','skipped'];
|
|
@@ -1028,7 +1071,7 @@ function chartDurationHistogram(){
|
|
|
1028
1071
|
{label:'30s+', min:30000,max:Infinity},
|
|
1029
1072
|
];
|
|
1030
1073
|
const counts = buckets.map(()=>0);
|
|
1031
|
-
for(const t of data.tests){ const d=t.duration||0; for(let i=0;i<buckets.length;i++){ if(d>=buckets[i].min && d<buckets[i].max){ counts[i]++; break; } } }
|
|
1074
|
+
for(const t of data.tests){ if(t.outcome==='skipped') continue; const d=t.duration||0; for(let i=0;i<buckets.length;i++){ if(d>=buckets[i].min && d<buckets[i].max){ counts[i]++; break; } } }
|
|
1032
1075
|
const maxN = Math.max(1,...counts);
|
|
1033
1076
|
const W=520, colW=80, gap=16, padL=32, padT=24, padB=36, chartH=180;
|
|
1034
1077
|
const H = chartH+padT+padB;
|
|
@@ -1043,7 +1086,7 @@ function chartDurationHistogram(){
|
|
|
1043
1086
|
|
|
1044
1087
|
/* Chart 4: multi-line — pass rate per priority over the last N history entries */
|
|
1045
1088
|
function chartTrendByPriority(){
|
|
1046
|
-
const hist =
|
|
1089
|
+
const hist = TREND;
|
|
1047
1090
|
if(hist.length < 2) return null;
|
|
1048
1091
|
const orders = ['P0','P1','P2','P3','P4'];
|
|
1049
1092
|
const seenPri = new Set();
|
|
@@ -1195,12 +1238,12 @@ function summary(){
|
|
|
1195
1238
|
const hasFail=data.tests.some(t=>isFail(t.outcome)||t.outcome==='flaky');
|
|
1196
1239
|
if(W.attention && hasFail) g.push(h('div',{class:'card w6 fail-rail'}, h('h2',{},'Needs attention', h('span',{class:'hint'},(failed+s.flaky)+' open · ranked by priority and severity')), attention(8)));
|
|
1197
1240
|
const clusters=failureClusters(); if(clusters.length) g.push(h('div',{class:'card w6 fail-rail'}, h('h2',{},'Failure clusters', h('span',{class:'hint'},clusters.length+' root cause'+(clusters.length>1?'s':'')+' · '+clusters.reduce((a,c)=>a+c.tests.length,0)+' failures')), clustersView(clusters.slice(0,6)), clusters.length>6?h('div',{class:'attn-foot'}, h('button',{onclick:()=>showView('failures')},'View all '+clusters.length+' clusters →')):null));
|
|
1198
|
-
if(W.dimensions) g.push(h('div',{class:'card w8'}, h('h2',{},'Breakdown', h('span',{class:'hint'},'click a row to filter')), breakdown()));
|
|
1241
|
+
if(W.dimensions) g.push(h('div',{class:'card w8 bd-card'}, h('h2',{},'Breakdown', h('span',{class:'hint'},'click a row to filter')), breakdown()));
|
|
1199
1242
|
if(W.slowest){ const reg=regressions().length; g.push(h('div',{class:'card'}, h('h2',{},'Slowest tests', reg?h('span',{class:'hint badge'},reg+' got slower'):null), tabbed([['slow','Slowest',slowest],['reg','Got slower'+(reg?' ('+reg+')':''),slowerView]]))); }
|
|
1200
1243
|
if(W.flaky!==false && HRUNS.length>1) g.push(h('div',{class:'card'}, h('h2',{},'Flakiest tests', h('span',{class:'hint'},'last '+Math.min(10,HRUNS.length)+' runs')), flakiest()));
|
|
1201
1244
|
if(W.skipped!==false) { const sc=skippedCard(); if(sc) g.push(sc); }
|
|
1202
1245
|
if(W.environment!==false) { const ec=envCard(); if(ec) g.push(ec); }
|
|
1203
|
-
if(data.history.length>1) g.push(h('div',{class:'card w12'}, h('h2',{},'Trend', h('span',{class:'hint'},'last '+
|
|
1246
|
+
if(data.history.length>1) g.push(h('div',{class:'card w12'}, h('h2',{},'Trend', h('span',{class:'hint'},'last '+TREND.length+' runs'+(SMALL_HIDDEN?' · '+SMALL_HIDDEN+' small run'+(SMALL_HIDDEN>1?'s':'')+' (under '+Math.ceil(MED_SIZE/2)+' tests) not plotted':''))), trend()));
|
|
1204
1247
|
else if(data.history.length===1) g.push(h('div',{class:'card w12'}, h('h2',{},'Trend', h('span',{class:'hint'},'from the second run')), h('div',{class:'nudge'}, h('b',{},'First run recorded.'),' Run the suite once more and this card shows the pass-rate trend, and every failure gets "new this run" or "failing since #…", last-10-runs dots, a Flakiest tests card and a Got slower tab. History lives in ',h('code',{},data.historyFile||'reporting-labs.history.json'),', commit it or cache it in CI.')));
|
|
1205
1248
|
const ge=data.globalErrors||[], go=data.globalOutput||[];
|
|
1206
1249
|
if(ge.length||go.some(x=>x.stream==='err')) g.unshift(h('div',{class:'card w12 fail-rail'}, h('h2',{}, ge.length? ge.length+' error'+(ge.length>1?'s':'')+' outside tests' : 'Output outside tests', h('span',{class:'hint'},'spec files that failed to load, global setup, worker crashes')),
|
|
@@ -1214,10 +1257,15 @@ function pill(key,text,n,color,sub,tone,series){
|
|
|
1214
1257
|
series? spark(series,color) : null, h('div',{class:'l'},text), h('div',{class:'v'},n), h('div',{class:'d '+(tone||'')},sub||''));
|
|
1215
1258
|
}
|
|
1216
1259
|
function initials(name){ return (name||'').split(/[\s\-–—·]+/).filter(Boolean).slice(0,2).map(w=>w[0]).join('').toUpperCase()||'R'; }
|
|
1260
|
+
/* The run this one is compared with: the latest earlier run of a comparable size (a full run is not compared
|
|
1261
|
+
with a one-spec debug run, nor a debug run with the full suite); with a build label, a different build. */
|
|
1217
1262
|
function prevRun(){
|
|
1218
1263
|
const H=data.history||[]; if(H.length<2) return null;
|
|
1219
|
-
const cur=H[H.length-1]
|
|
1220
|
-
|
|
1264
|
+
const cur=H[H.length-1], n=cur.total||0;
|
|
1265
|
+
const comparable=e=> isSmallRun(cur) ? (e.total||0)>=n/2 && (e.total||0)<=n*2 : !isSmallRun(e);
|
|
1266
|
+
let fallback=null;
|
|
1267
|
+
for(let i=H.length-2;i>=0;i--){ if(!comparable(H[i])) continue; if(!cur.label||H[i].label!==cur.label) return H[i]; if(!fallback) fallback=H[i]; }
|
|
1268
|
+
return fallback;
|
|
1221
1269
|
}
|
|
1222
1270
|
|
|
1223
1271
|
/* ---------- run history per test ---------- */
|
|
@@ -1265,7 +1313,7 @@ function slowerView(){
|
|
|
1265
1313
|
const rows=regressions().slice(0,6);
|
|
1266
1314
|
if(!rows.length) return h('div',{class:'ok'},'No test got 2× slower than last run.');
|
|
1267
1315
|
return h('ul',{class:'slow'}, rows.map(({t,prev,cur,ratio})=>h('li',{}, h('button',{onclick:()=>select(t.id)},
|
|
1268
|
-
h('span',{}, h('span',{class:'t'},t.title), h('span',{class:'bar',style:'--w:'+Math.min(100,Math.round(prev/cur*100))+'%'}, h('i',{style:'background:var(--flaky)'}))),
|
|
1316
|
+
h('span',{}, h('span',{class:'t'},t.title, data.projects.length>1?h('small',{class:'proj-sfx'},' · '+t.project):null), h('span',{class:'bar',style:'--w:'+Math.min(100,Math.round(prev/cur*100))+'%'}, h('i',{style:'background:var(--flaky)'}))),
|
|
1269
1317
|
h('span',{class:'d'}, h('b',{style:'color:var(--fail)'},'×'+(ratio>=10?Math.round(ratio):ratio.toFixed(1))), ' ', ms(prev)+' → '+ms(cur))))));
|
|
1270
1318
|
}
|
|
1271
1319
|
/* sparklines from the whole history (counts, no per-test data needed) */
|
|
@@ -1276,7 +1324,7 @@ function spark(series, color, w, h2){
|
|
|
1276
1324
|
const pts=series.map((v,i)=>x(i)+','+y(v)).join(' ');
|
|
1277
1325
|
return h('span',{class:'sp','aria-hidden':'true',html:'<svg viewBox="0 0 '+w+' '+h2+'" width="'+w+'" height="'+h2+'"><polyline points="'+pts+'" fill="none" stroke="'+color+'" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round" opacity=".9"/><circle cx="'+x(n-1)+'" cy="'+y(series[n-1])+'" r="2.2" fill="'+color+'"/></svg>'});
|
|
1278
1326
|
}
|
|
1279
|
-
function hseries(f){ return
|
|
1327
|
+
function hseries(f){ return TREND.slice(-12).map(f); }
|
|
1280
1328
|
/* owner rollup for the Failures view and the copied summary */
|
|
1281
1329
|
function byOwner(){
|
|
1282
1330
|
const m=new Map();
|
|
@@ -1414,13 +1462,28 @@ function donut(){
|
|
|
1414
1462
|
const svg=h('div',{html:'<svg viewBox="0 0 132 132">'+arcs+'<text class="c" x="66" y="64" text-anchor="middle">'+rate+'%</text><text class="cl" x="66" y="80" text-anchor="middle">pass rate</text></svg>'}).firstChild;
|
|
1415
1463
|
return h('div',{class:'donut'}, svg, h('div',{class:'legend'}, parts.map(([k,n])=>h('button',{onclick:()=>{state.status=k;refresh();showView('tests');}}, h('span',{class:'dot',style:'background:'+colorOf(k)}), h('span',{},label[k]), h('span',{class:'n'},n)))));
|
|
1416
1464
|
}
|
|
1465
|
+
/* Cross-project signal. The same test (file, describe path, title) runs once per project — a browser, a
|
|
1466
|
+
device. Failing in every project it ran in points at the app or the test itself; failing in some projects
|
|
1467
|
+
only points at those browsers. Skipped runs do not count; a flaky pass counts as a pass. */
|
|
1468
|
+
var XP=null; // var: the views are built before this line runs
|
|
1469
|
+
function xpKey(t){ return t.file+'::'+t.path.join('/')+'::'+t.title; }
|
|
1470
|
+
function xproj(t){
|
|
1471
|
+
if(data.projects.length<2) return null;
|
|
1472
|
+
if(!XP){ XP=new Map(); for(const u of data.tests){ if(u.outcome==='skipped') continue; const g=XP.get(xpKey(u))||[]; g.push(u); XP.set(xpKey(u),g); } }
|
|
1473
|
+
const g=XP.get(xpKey(t))||[]; if(g.length<2) return null;
|
|
1474
|
+
const bad=g.filter(u=>isFail(u.outcome)); if(!bad.length) return null;
|
|
1475
|
+
if(bad.length===g.length) return {all:true, text:g.length===2?'fails on both projects':'fails on all '+g.length+' projects', tip:'The same failure in every project it ran in: look at the app or the test, not the browser'};
|
|
1476
|
+
const names=bad.map(u=>u.project), good=g.filter(u=>!isFail(u.outcome)).map(u=>u.project);
|
|
1477
|
+
return {all:false, text: names.length<=2? 'only on '+names.join(' and ') : 'on '+names.length+' of '+g.length+' projects', tip:'Passes on '+good.join(', ')+': specific to '+names.join(', ')};
|
|
1478
|
+
}
|
|
1479
|
+
function xpTag(xp,cls){ return h('span',{class:'xp '+(xp.all?'all':'some')+(cls?' '+cls:''),title:xp.tip},xp.text); }
|
|
1417
1480
|
function attention(limit){
|
|
1418
1481
|
const bad=data.tests.filter(t=>isFail(t.outcome)||t.outcome==='flaky').sort((a,b)=>rank(a)-rank(b)||(isFail(b.outcome)-isFail(a.outcome))||b.duration-a.duration);
|
|
1419
1482
|
if(!bad.length) return h('div',{class:'ok'},'No failures. Ship it.');
|
|
1420
1483
|
const groups=new Map(); for(const t of bad){ const k=t.file+'::'+t.path.join('/')+'::'+t.title; const g=groups.get(k)||{t,projects:[]}; g.projects.push(t.project); groups.set(k,g); }
|
|
1421
1484
|
const all=[...groups.values()], rows=limit?all.slice(0,limit):all;
|
|
1422
1485
|
const sub=(t,projects)=>{ const parts=[t.file.split('/').pop()];
|
|
1423
|
-
if(data.projects.length>1) parts.push(projects.length>1? projects.length+' projects' : projects[0]);
|
|
1486
|
+
if(data.projects.length>1){ const xp=xproj(t); parts.push(xp? xpTag(xp) : projects.length>1? projects.length+' projects' : projects[0]); }
|
|
1424
1487
|
const si=sinceInfo(t); if(si) parts.push(si.kind==='new'? h('b',{class:'new'},'new this run') : 'failing since '+runLabel(si.since));
|
|
1425
1488
|
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));
|
|
1426
1489
|
else if(t.outcome==='flaky') parts.push('passed on retry '+t.results.length);
|
|
@@ -1504,7 +1567,7 @@ function clusterLine(c){
|
|
|
1504
1567
|
// in place and "show fewer" folds it again. Each name opens that test.
|
|
1505
1568
|
function clusterWho(byTitle, all){
|
|
1506
1569
|
const entries=[...byTitle.entries()], names=all?entries:entries.slice(0,6), extra=entries.length-names.length;
|
|
1507
|
-
const who=names.map(([title,ts])=>h('button',{onclick:()=>select(ts[0].id),title:ts.map(t=>t.project).join(', ')}, h('span',{class:'tri'},'›'), title, data.projects.length>1&&ts.length>1? h('small',{},' ×'+ts.length) : null));
|
|
1570
|
+
const who=names.map(([title,ts])=>h('button',{onclick:()=>select(ts[0].id),title:ts.map(t=>t.project).join(', ')}, h('span',{class:'tri'},'›'), title, data.projects.length>1&&ts.length>1? h('small',{},' ×'+ts.length) : null, (()=>{ const xp=xproj(ts[0]); return xp? h('small',{class:'xp '+(xp.all?'all':'some'),title:xp.tip},' · '+xp.text) : null; })()));
|
|
1508
1571
|
const swap=(ev,toAll)=>{ const d=ev.currentTarget.closest('.who'); d.replaceChildren(); for(const n of clusterWho(byTitle,toAll)) d.append(n); };
|
|
1509
1572
|
if(extra>0) who.push(h('button',{class:'ex',onclick:ev=>swap(ev,true),title:'Show all '+entries.length+' tests'},'+'+extra+' more'));
|
|
1510
1573
|
else if(all&&entries.length>6) who.push(h('button',{class:'ex',onclick:ev=>swap(ev,false)},'show fewer'));
|
|
@@ -1518,7 +1581,7 @@ function clustersView(cl){
|
|
|
1518
1581
|
}));
|
|
1519
1582
|
}
|
|
1520
1583
|
function trend(){
|
|
1521
|
-
const H=
|
|
1584
|
+
const H=TREND, W=760, HT=170, padL=34, padR=12, padT=12, padB=26, n=H.length;
|
|
1522
1585
|
const x=i=>padL+(n===1?0:i/(n-1))*(W-padL-padR), y=v=>padT+(1-v/100)*(HT-padT-padB);
|
|
1523
1586
|
const rate=e=>{ const ran=e.total-e.skipped; return ran?Math.round(e.passed/ran*100):0; };
|
|
1524
1587
|
const failPct=e=>{ const ran=e.total-e.skipped; return ran?Math.round(e.failed/ran*100):0; };
|
|
@@ -1528,9 +1591,14 @@ function trend(){
|
|
|
1528
1591
|
svg+='<path class="area" d="M'+x(0)+','+y(0)+' '+H.map((e,i)=>'L'+x(i)+','+y(rate(e))).join(' ')+' L'+x(n-1)+','+y(0)+' Z"/>';
|
|
1529
1592
|
svg+='<polyline class="fail" points="'+H.map((e,i)=>x(i)+','+y(failPct(e))).join(' ')+'"/>';
|
|
1530
1593
|
svg+='<polyline class="pass" points="'+H.map((e,i)=>x(i)+','+y(rate(e))).join(' ')+'"/>';
|
|
1531
|
-
svg+=H.map((e,i)=>'<circle class="pt'+(i===n-1?' now':'')+'" cx="'+x(i)+'" cy="'+y(rate(e))+'" r="3.5"><title>'+new Date(e.time).toLocaleString()+(e.label?' · '+e.label:'')+' · '+rate(e)+'% pass · '+e.failed+' failed · '+ms(e.duration)+'</title></circle>').join('');
|
|
1532
|
-
|
|
1533
|
-
|
|
1594
|
+
svg+=H.map((e,i)=>'<circle class="pt'+(i===n-1&&e===HIST[HIST.length-1]?' now':'')+'" cx="'+x(i)+'" cy="'+y(rate(e))+'" r="3.5"><title>'+new Date(e.time).toLocaleString()+(e.label?' · '+e.label:'')+' · '+rate(e)+'% pass · '+e.failed+' failed · '+ms(e.duration)+'</title></circle>').join('');
|
|
1595
|
+
// Several runs on one day get the time too; longer labels mean fewer of them, so they never overlap
|
|
1596
|
+
const day=e=>new Date(e.time).toLocaleDateString(undefined,{month:'short',day:'numeric'});
|
|
1597
|
+
const days=H.filter(e=>!e.label).map(day), twice=days.length!==new Set(days).size;
|
|
1598
|
+
const step=Math.max(1,Math.ceil(n/(twice?5:8)));
|
|
1599
|
+
const shown=H.map((e,i)=>i===n-1||(i%step===0&&n-1-i>=step));
|
|
1600
|
+
const axis=e=> e.label || (twice ? day(e)+' '+new Date(e.time).toLocaleTimeString(undefined,{hour:'numeric',minute:'2-digit'}) : day(e));
|
|
1601
|
+
svg+=H.map((e,i)=>shown[i]?'<text x="'+x(i)+'" y="'+(HT-6)+'" text-anchor="'+(i===n-1&&n>1?'end':i===0&&twice?'start':'middle')+'">'+escape(axis(e))+'</text>':'').join('');
|
|
1534
1602
|
const colW=n>1?(x(1)-x(0)):(W-padL-padR);
|
|
1535
1603
|
svg+='<line class="guide" x1="0" x2="0" y1="'+padT+'" y2="'+y(0)+'" style="display:none"/>';
|
|
1536
1604
|
svg+=H.map((e,i)=>'<rect class="hit" data-i="'+i+'" x="'+(x(i)-colW/2)+'" y="0" width="'+colW+'" height="'+HT+'" fill="transparent"/>').join('');
|
|
@@ -1555,7 +1623,7 @@ function trend(){
|
|
|
1555
1623
|
svgEl.addEventListener('mousemove',ev=>{ const r=ev.target.closest&&ev.target.closest('.hit'); if(!r){ tip.hidden=true; guide.style.display='none'; return; } show(+r.dataset.i,ev); });
|
|
1556
1624
|
svgEl.addEventListener('mouseleave',()=>{ tip.hidden=true; guide.style.display='none'; });
|
|
1557
1625
|
wrap.append(
|
|
1558
|
-
h('div',{class:'trend-cap'}, h('span',{style:'color:var(--pass)'},'— pass rate'), h('span',{style:'color:var(--fail)'},'- - fail rate'), h('span',{style:'color:var(--accent)'},'▮ duration'), h('span',{style:'margin-left:auto'}, 'this run: '+rate(
|
|
1626
|
+
h('div',{class:'trend-cap'}, h('span',{style:'color:var(--pass)'},'— pass rate'), h('span',{style:'color:var(--fail)'},'- - fail rate'), h('span',{style:'color:var(--accent)'},'▮ duration'), h('span',{style:'margin-left:auto'}, 'this run: '+rate(HIST[HIST.length-1])+'% · '+ms(HIST[HIST.length-1].duration)+(isSmallRun(HIST[HIST.length-1])?' · small run, not plotted':''))));
|
|
1559
1627
|
return wrap;
|
|
1560
1628
|
}
|
|
1561
1629
|
function workers(){
|
|
@@ -1594,7 +1662,7 @@ function slowest(){
|
|
|
1594
1662
|
const top=[...data.tests].filter(t=>t.outcome!=='skipped').sort((a,b)=>b.duration-a.duration).slice(0,5);
|
|
1595
1663
|
const max=top[0]?top[0].duration:1;
|
|
1596
1664
|
return h('ul',{class:'slow'}, top.map(t=>h('li',{}, h('button',{onclick:()=>select(t.id)},
|
|
1597
|
-
h('span',{}, h('span',{class:'t'},t.title), h('span',{class:'bar',style:'--w:'+Math.max(4,Math.round(t.duration/max*100))+'%'}, h('i',{}))), h('span',{class:'d'},ms(t.duration))))));
|
|
1665
|
+
h('span',{}, h('span',{class:'t'},t.title, data.projects.length>1?h('small',{class:'proj-sfx'},' · '+t.project):null), h('span',{class:'bar',style:'--w:'+Math.max(4,Math.round(t.duration/max*100))+'%'}, h('i',{}))), h('span',{class:'d'},ms(t.duration))))));
|
|
1598
1666
|
}
|
|
1599
1667
|
function projects(){
|
|
1600
1668
|
return h('div',{class:'proj'}, data.projects.map(p=>{
|
|
@@ -1698,7 +1766,7 @@ function renderDetail(t, d, retryIdx){
|
|
|
1698
1766
|
h('button',{class:'btn',onclick:e=>copyText(location.href.split('#')[0]+'#t='+t.id,e.currentTarget,'Link copied')},'Copy link'),
|
|
1699
1767
|
(()=>{ 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; })(),
|
|
1700
1768
|
editorLink(t))),
|
|
1701
|
-
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:''))));
|
|
1769
|
+
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, (()=>{ const xp=isFail(t.outcome)&&xproj(t); return xp? xpTag(xp,'badge') : 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:''))));
|
|
1702
1770
|
const metaKeys=Object.keys(t.meta);
|
|
1703
1771
|
const linkFor=(k,v)=>{ if(t.links&&t.links[k]) return t.links[k]; const tpl=data.options.links[k]||data.options.links['*']; if(tpl) return tpl.replace('{id}',encodeURIComponent(v)); if(/^https?:\/\//.test(v)) return v; return null; };
|
|
1704
1772
|
if(metaKeys.length) d.append(h('div',{class:'metas'}, metaKeys.map(k=>{ const v=t.meta[k], low=/^(P[3-4]|low|minor|trivial|normal|medium)$/i.test(v); const parts=(t.links&&t.links[k])||!data.options.links[k]&&!data.options.links['*']?[v]:v.split(/,\s+/); const nodes=[]; parts.forEach((part,i)=>{ const href=linkFor(k,part); if(i) nodes.push(', '); nodes.push(href? h('a',{href,target:'_blank',rel:'noopener'},part) : h('span',{class:'v'},part)); }); return h('span',{class:'meta '+k+(low?' low':'')}, h('span',{class:'k'},k), ...nodes); })));
|
|
@@ -1714,7 +1782,7 @@ function renderDetail(t, d, retryIdx){
|
|
|
1714
1782
|
if(r.steps.length){ const stepTotal=Math.max(1, r.duration||0, r.steps.reduce((a,x)=>a+x.duration,0)); body.append(h('h4',{},data.bdd?'Scenario steps':'Steps', h('span',{class:'hint'},'bar = share of '+ms(stepTotal))), stepTree(r.steps, stepTotal)); }
|
|
1715
1783
|
if(r.logs.length){ const t0=r.startTime; body.append(h('h4',{},'Log'), h('div',{class:'logs'}, r.logs.map(l=>h('div',{class:'ln'+(/\b(error|fail|exception)\b/i.test(l.msg)?' err':/\bwarn/i.test(l.msg)?' warn':'')}, h('span',{class:'ts'},'+'+ms(Math.max(0,l.t-t0))), h('span',{class:'lm'},l.msg))))); }
|
|
1716
1784
|
for(const b of r.data){ body.append(h('h4',{},b.name), dataBlock(b)); }
|
|
1717
|
-
if(r.api.length){ body.append(h('h4',{},r.api.length+' API call'+(r.api.length>1?'s':'')), ...r.api.map(apiPanel)); body.append(h('div',{class:'mask-note'},'Secrets and auth headers are masked as ****')); }
|
|
1785
|
+
if(r.api.length){ body.append(h('h4',{},r.api.length+' API call'+(r.api.length>1?'s':'')), ...r.api.map(c=>apiPanel(c,r))); body.append(h('div',{class:'mask-note'},'Secrets and auth headers are masked as ****')); }
|
|
1718
1786
|
const imgs=r.attachments.filter(a=>a.src&&a.contentType.startsWith('image/'));
|
|
1719
1787
|
const vids=r.attachments.filter(a=>a.src&&a.contentType.startsWith('video/'));
|
|
1720
1788
|
const traces=r.attachments.filter(a=>a.src&&(a.name==='trace'||/\.zip$/.test(a.src)));
|
|
@@ -1759,10 +1827,10 @@ function dataBlock(b){
|
|
|
1759
1827
|
if(b.kind==='kv') return h('dl',{class:'kv2'}, b.kv.map(([k,v])=>[h('dt',{},k),h('dd',{class:v==='****'?'mask':''},v)]));
|
|
1760
1828
|
return h('pre',{class:'txt'},b.text);
|
|
1761
1829
|
}
|
|
1762
|
-
function apiPanel(c){
|
|
1763
|
-
const st=c.status||0, cls=
|
|
1830
|
+
function apiPanel(c,r){
|
|
1831
|
+
const st=c.status||0, cls=apiCls(c,r);
|
|
1764
1832
|
const wrap=h('div',{class:'api collapsed'});
|
|
1765
|
-
const head=h('button',{class:'api-head',onclick:()=>wrap.classList.toggle('collapsed')}, h('span',{class:'m '+c.method.toUpperCase()},c.method.toUpperCase()), h('span',{class:'u',title:c.url},c.url), st?h('span',{class:'sc '+cls},st):null, c.duration!=null?h('span',{class:'d'},ms(c.duration)):null);
|
|
1833
|
+
const head=h('button',{class:'api-head',onclick:()=>wrap.classList.toggle('collapsed')}, h('span',{class:'m '+c.method.toUpperCase()},c.method.toUpperCase()), h('span',{class:'u',title:c.url},c.url), st?h('span',{class:'sc '+cls,title:cls==='exp'?'The test passed: this is the response it expected':''},st):null, cls==='exp'?h('span',{class:'sc-exp'},'expected'):null, c.duration!=null?h('span',{class:'d'},ms(c.duration)):null);
|
|
1766
1834
|
const pre=v=>v==null?null:h('pre',{}, typeof v==='string'?v:JSON.stringify(v,null,2));
|
|
1767
1835
|
const col=(title,headers,bodyv)=>h('div',{class:'api-col'}, h('h5',{},title), headers&&Object.keys(headers).length? [h('h5',{},'Headers'), pre(headers)] : null, bodyv!=null? [h('h5',{},'Body'), pre(bodyv)] : h('div',{style:'font-size:12px;color:var(--ink-3)'},'no body'));
|
|
1768
1836
|
const tools=h('div',{class:'api-tools'}, h('button',{class:'btn',onclick:e=>copyText(curlOf(c),e.currentTarget,'Copied')},'Copy as cURL'), h('button',{class:'btn',onclick:e=>copyText(c.url,e.currentTarget,'Copied')},'Copy URL'));
|
package/dist/types.d.ts
CHANGED
|
@@ -19,10 +19,13 @@ export interface ReportingLabsOptions {
|
|
|
19
19
|
jsonFile?: string;
|
|
20
20
|
/** Also write a print-ready `report.pdf` next to the HTML, rendered by headless Chromium from the
|
|
21
21
|
* report's own PDF layout (executive summary, charts, failure analysis, the full test-case table and
|
|
22
|
-
* screenshots of failures).
|
|
23
|
-
*
|
|
22
|
+
* screenshots of failures). Printed by the Chromium that Playwright provides, else by an installed
|
|
23
|
+
* Chrome, Edge or Chromium, whichever browser the tests ran on (Firefox and WebKit runs included). Default: true — set `false` to skip,
|
|
24
|
+
* `{ file }` to rename, `{ chromePath }` to name the browser (or set CHROME_PATH). The report also has
|
|
25
|
+
* an "Export PDF" button. */
|
|
24
26
|
pdf?: boolean | {
|
|
25
27
|
file?: string;
|
|
28
|
+
chromePath?: string;
|
|
26
29
|
};
|
|
27
30
|
/** Inline screenshots as base64 (single file, opens anywhere). Default: true */
|
|
28
31
|
embedAttachments?: boolean;
|
package/dist/wdio/finalize.d.ts
CHANGED
package/dist/wdio/finalize.js
CHANGED
|
@@ -48,6 +48,7 @@ const fs = __importStar(require("fs"));
|
|
|
48
48
|
const path = __importStar(require("path"));
|
|
49
49
|
const template_1 = require("../template");
|
|
50
50
|
const mask_1 = require("../mask");
|
|
51
|
+
const pdf_1 = require("../pdf");
|
|
51
52
|
const env_1 = require("./env");
|
|
52
53
|
async function reportingLabsComplete(options = {}) {
|
|
53
54
|
const outputFolder = options.outputFolder || 'reporting-labs';
|
|
@@ -176,28 +177,13 @@ async function reportingLabsComplete(options = {}) {
|
|
|
176
177
|
console.log('');
|
|
177
178
|
return htmlFile;
|
|
178
179
|
}
|
|
179
|
-
/**
|
|
180
|
+
/** report.pdf next to the HTML; see writeReportPdf for which browser prints it (an installed Chrome or
|
|
181
|
+
* Edge when the project has no Playwright). Never fails the run. */
|
|
180
182
|
async function writePdf(htmlFile, outputFolder, options) {
|
|
181
|
-
const
|
|
182
|
-
const pdfFile = path.join(outputFolder,
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
try {
|
|
188
|
-
const page = await browser.newPage();
|
|
189
|
-
await page.goto(pathToFileURL(htmlFile).href, { waitUntil: 'load' });
|
|
190
|
-
await page.evaluate(() => window.reportingLabsPreparePrint?.());
|
|
191
|
-
await page.pdf({ path: pdfFile, printBackground: true, preferCSSPageSize: true });
|
|
192
|
-
}
|
|
193
|
-
finally {
|
|
194
|
-
await browser.close();
|
|
195
|
-
}
|
|
196
|
-
if (options.announce !== false)
|
|
197
|
-
console.log(` reporting-labs: PDF written to ${path.relative(process.cwd(), pdfFile)}`);
|
|
198
|
-
}
|
|
199
|
-
catch (e) {
|
|
200
|
-
if (options.announce !== false)
|
|
201
|
-
console.log(` reporting-labs: PDF skipped — ${String(e.message).split('\n')[0]}`);
|
|
202
|
-
}
|
|
183
|
+
const pdfOpt = typeof options.pdf === 'object' ? options.pdf : {};
|
|
184
|
+
const pdfFile = path.join(outputFolder, pdfOpt.file || 'report.pdf');
|
|
185
|
+
const r = await (0, pdf_1.writeReportPdf)(htmlFile, pdfFile, pdfOpt.chromePath);
|
|
186
|
+
if (options.announce === false)
|
|
187
|
+
return;
|
|
188
|
+
console.log(r.ok ? ` reporting-labs: PDF written to ${path.relative(process.cwd(), pdfFile)}` : ` reporting-labs: PDF skipped — ${r.reason}`);
|
|
203
189
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "reporting-labs",
|
|
3
|
-
"version": "0.6.
|
|
4
|
-
"description": "reportingLabs
|
|
3
|
+
"version": "0.6.12",
|
|
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",
|
|
7
7
|
"html-report",
|