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 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) with Playwright's Chromium. `false` turns it off; `{ file: 'run.pdf' }` renames it. The Export PDF button in the report works either way |
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
+ }
@@ -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
- /** Render the report's print layout to a PDF with the Chromium that Playwright already ships. Best-effort:
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
- /** Render the report's print layout to a PDF with the Chromium that Playwright already ships. Best-effort:
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 name = (typeof this.options.pdf === 'object' && this.options.pdf?.file) || 'report.pdf';
254
- const pdfFile = path.join(this.outDir, name);
255
- try {
256
- const { chromium } = await import('@playwright/test');
257
- const { pathToFileURL } = await import('url');
258
- const browser = await chromium.launch();
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) {
@@ -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.c.status||0)>=400).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;
833
- calls.sort((a,b)=>((b.c.status||0)>=400)-((a.c.status||0)>=400)||(b.c.duration||0)-(a.c.duration||0));
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, cls=st>=400?'bad':st>=300?'warn':'ok'; return h('tr',{onclick:()=>select(t.id)},
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 test durations are spread across the run.', chartDurationHistogram),
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 = data.history || [];
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 '+data.history.length+' runs')), trend()));
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]; for(let i=H.length-2;i>=0;i--){ if(!cur.label||H[i].label!==cur.label) return H[i]; }
1154
- return H[H.length-2];
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 HIST.slice(-12).map(f); }
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=data.history, W=760, HT=170, padL=34, padR=12, padT=12, padB=26, n=H.length;
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
- const step=Math.max(1,Math.ceil(n/8));
1467
- svg+=H.map((e,i)=>(i===n-1||(i%step===0&&n-1-i>=step))?'<text x="'+x(i)+'" y="'+(HT-6)+'" text-anchor="'+(i===n-1&&n>1?'end':'middle')+'">'+(e.label||new Date(e.time).toLocaleDateString(undefined,{month:'short',day:'numeric'}))+'</text>':'').join('');
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(H[n-1])+'% · '+ms(H[n-1].duration))));
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=st>=500||st>=400?'bad':st>=300?'warn':'ok';
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.c.status||0)>=400).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;
899
- calls.sort((a,b)=>((b.c.status||0)>=400)-((a.c.status||0)>=400)||(b.c.duration||0)-(a.c.duration||0));
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, cls=st>=400?'bad':st>=300?'warn':'ok'; return h('tr',{onclick:()=>select(t.id)},
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 test durations are spread across the run.', chartDurationHistogram),
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 = data.history || [];
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 '+data.history.length+' runs')), trend()));
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]; for(let i=H.length-2;i>=0;i--){ if(!cur.label||H[i].label!==cur.label) return H[i]; }
1220
- return H[H.length-2];
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 HIST.slice(-12).map(f); }
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=data.history, W=760, HT=170, padL=34, padR=12, padT=12, padB=26, n=H.length;
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
- const step=Math.max(1,Math.ceil(n/8));
1533
- svg+=H.map((e,i)=>(i===n-1||(i%step===0&&n-1-i>=step))?'<text x="'+x(i)+'" y="'+(HT-6)+'" text-anchor="'+(i===n-1&&n>1?'end':'middle')+'">'+(e.label||new Date(e.time).toLocaleDateString(undefined,{month:'short',day:'numeric'}))+'</text>':'').join('');
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(H[n-1])+'% · '+ms(H[n-1].duration))));
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=st>=500||st>=400?'bad':st>=300?'warn':'ok';
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). Needs the Chromium that Playwright already provides. Default: true —
23
- * set `false` to skip, or `{ file }` to rename. The report also has an "Export PDF" button. */
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;
@@ -5,6 +5,7 @@ export interface ReportingLabsCompleteOptions {
5
5
  emitJson?: boolean;
6
6
  pdf?: boolean | {
7
7
  file?: string;
8
+ chromePath?: string;
8
9
  };
9
10
  title?: string;
10
11
  logo?: string;
@@ -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
- /** Render the report's print layout to a PDF with the Chromium Playwright ships. Best-effort. */
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 name = (typeof options.pdf === 'object' && options.pdf?.file) || 'report.pdf';
182
- const pdfFile = path.join(outputFolder, name);
183
- try {
184
- const { chromium } = await import('@playwright/test');
185
- const { pathToFileURL } = await import('url');
186
- const browser = await chromium.launch();
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.11",
4
- "description": "reportingLabs \u2013 beautiful, customizable single-file HTML test reports. One line of config. Playwright adapter included, more runners on the way.",
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",