@reportforge/playwright-pdf 0.28.0 → 0.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -59,6 +59,7 @@ Three templates, one reporter. Every PDF is fully offline: fonts, charts, and sc
59
59
  - **Flakiness trend**: top-N flakiest tests table with per-test dot sparkline across stored history runs in the `detailed` template; `flakinessTopN` option (default 5, `0` disables)
60
60
  - **Pass-rate trend**: cross-run trend chart from stored history; opt-in `remoteHistory` keeps the trend alive on ephemeral CI runners by storing aggregate numbers only (pass rate, counts, duration, verdict; no test titles or error text, and the branch name never travels in plaintext), with no extra network calls
61
61
  - **Run-over-run diff**: every report shows what changed since the previous run on the same branch: new failures, fixed tests, and still-failing tests, and the same summary rides along in Slack, Teams, Discord, and email notifications
62
+ - **Cross-browser comparison**: multi-project runs get a per-test matrix across projects in the `detailed` template — divergent outcomes first (fails-only-in-webkit at a glance), identical tests collapsed to a count; works for Node.js multi-project configs and pytest runs with multiple `--browser` values
62
63
  - **Offline failure analysis**: sorts each failure into one of 7 root-cause buckets and renders a root-cause chip under each failed test in the `detailed` PDF (dominant-cause one-liner in `executive`). No network, no AI service: a small embedded classifier with automatic updates; pin it any time. Full details at [reportforge.org/docs/advanced/failure-analysis](https://reportforge.org/docs/advanced/failure-analysis).
63
64
  - **On-device training**: teach the classifier your team's failures without any data leaving your machine. Label locally-collected samples (`npx @reportforge/playwright-pdf label-feedback`), then `train-model` trains and cross-validates a personal layer on-device; it activates only when it measurably beats the base model on your own labeled set (`evaluate-model` shows the comparison any time). Commit the model file to your repo via `failureAnalysis.localModelPath` to share it with CI. Training data, the trained model, and evaluation are all local files; the analysis code cannot even name a network primitive (enforced by an automated source audit).
64
65
  - `npx @reportforge/playwright-pdf export-feedback`: merges your locally-collected feedback (tokenized + redacted) into one CSV for review or moving between machines. Local file only; there is no upload.
@@ -179,7 +180,7 @@ template = "detailed"
179
180
  outputFile = "reports/{date}-{branch}-report.pdf"
180
181
  ```
181
182
 
182
- The plugin reuses the Node runtime and Chromium that Playwright for Python already ships, so there is nothing else to install. pytest-xdist and pytest-rerunfailures are supported; failure screenshots from `--screenshot only-on-failure` are embedded. Live runs and step trees are Node.js-only today. Full guide: [reportforge.org/docs/python](https://reportforge.org/docs/python).
183
+ The plugin reuses the Node runtime and Chromium that Playwright for Python already ships, so there is nothing else to install. pytest-xdist and pytest-rerunfailures are supported; failure screenshots from `--screenshot only-on-failure` are embedded. Passing `--browser` more than once splits the report into one project per browser and unlocks the cross-browser comparison section. Live runs and step trees are Node.js-only today. Full guide: [reportforge.org/docs/python](https://reportforge.org/docs/python).
183
184
 
184
185
  ---
185
186
 
@@ -194,7 +195,7 @@ set RF_LICENSE_KEY=RFSU-XXXX-XXXX-XXXX-XXXX
194
195
  dotnet test --logger "reportforge;output=reports/run.pdf;template=detailed"
195
196
  ```
196
197
 
197
- Full options come from a JSON file with the same camelCase names as this reporter: `--logger "reportforge;optionsFile=rf-options.json"`. The logger reuses the Node runtime Microsoft.Playwright places in your test output and a Playwright-managed Chromium, so there is nothing else to install. Retried tests render as flaky with their attempt history; categories become tags. Live runs and step trees are Node.js-only today. Full guide: [reportforge.org/docs/dotnet](https://reportforge.org/docs/dotnet).
198
+ Full options come from a JSON file with the same camelCase names as this reporter: `--logger "reportforge;optionsFile=rf-options.json"`. The logger reuses the Node runtime Microsoft.Playwright places in your test output and a Playwright-managed Chromium, so there is nothing else to install. Retried tests render as flaky with their attempt history; categories become tags. A `dotnet test` run drives one browser, so the cross-browser comparison section stays hidden here; live runs and step trees are Node.js-only today. Full guide: [reportforge.org/docs/dotnet](https://reportforge.org/docs/dotnet).
198
199
 
199
200
  ---
200
201
 
@@ -255,7 +256,7 @@ All options are the second element of the reporter tuple.
255
256
  | `slowTestThreshold` | `number` | `10` | Minimum timed-test count before `SLOW` badges appear in the suite breakdown: below this, every test is trivially the "slowest" so badges stay hidden. On larger runs only genuine outliers (at least 2× the median test duration) are badged, so the badge stays rare. Set to `0` to drop the run-size gate (outliers still required). |
256
257
  | `requirementTagPattern` | `string` | `'^@?[A-Z][A-Z0-9]*-\\d+$'` | Regex deciding which tags count as requirement IDs in the Requirements Traceability section (`detailed` template). Matching tags (ticket shapes like `@ODP-5328`, `REQ-001`) get the traceability matrix; everything else (`@regression`, `@sanity`) collapses into a compact tag summary instead of repeating identical rows. Set to `''` to disable the split and list every tag in the matrix. |
257
258
  | `templatePath` | `string \| string[]` | n/a | Path to a custom Handlebars (`.hbs`) template file. Takes precedence over `template`. Pass an array to generate one PDF per custom template. See the Custom Templates docs. |
258
- | `sections` | `object` | per-template | Override which report sections appear, on top of the chosen template's defaults. Flat keys are the baseline for every chosen template; per-template keys (`minimal`\|`detailed`\|`executive`) override per template. 20 keys total. Block toggles: `coverPage`, `analysisOneliner`, `releaseGate`, `summary`, `charts`, `trend`, `requirementsMatrix`, `ciEnvironment`, `suiteBreakdown`, `failureDeepDive`, `failureAnalysis`, `slowTests`, `defectLog`, `briefBand`, `runDiff`. Display modifiers: `passRate`, `fullEnvironment`, `retries`, `fullFailures`, `stackTraces`. `runDiff` (block toggle, on by default in all three templates) shows new failures, fixed tests, and still-failing tests compared with the previous run on the same branch. See the Report Sections docs. |
259
+ | `sections` | `object` | per-template | Override which report sections appear, on top of the chosen template's defaults. Flat keys are the baseline for every chosen template; per-template keys (`minimal`\|`detailed`\|`executive`) override per template. 21 keys total. Block toggles: `coverPage`, `analysisOneliner`, `releaseGate`, `summary`, `charts`, `trend`, `requirementsMatrix`, `ciEnvironment`, `suiteBreakdown`, `failureDeepDive`, `failureAnalysis`, `slowTests`, `defectLog`, `briefBand`, `runDiff`, `browserMatrix`. Display modifiers: `passRate`, `fullEnvironment`, `retries`, `fullFailures`, `stackTraces`. `runDiff` (block toggle, on by default in all three templates) shows new failures, fixed tests, and still-failing tests compared with the previous run on the same branch. `browserMatrix` (block toggle, on by default in `detailed`) compares per-test outcomes across the run's projects and appears only when the run has 2+ projects. See the Report Sections docs. |
259
260
  <!-- AUTOGEN:options-table END -->
260
261
 
261
262
  Full reference: [reportforge.org/docs/configuration](https://reportforge.org/docs/configuration).
@@ -542,6 +543,8 @@ reporter: [['@reportforge/playwright-pdf', {
542
543
  | `slowTests` | `SLOW` badge on the duration-ranked slowest tests, shown inline in the breakdown |
543
544
  | `defectLog` | `DEF-####` numbered failure table (severity, duration) + opt-in repro detail (`capture`) |
544
545
  | `briefBand` | Executive brief band: one plain-language sentence (trend delta + root causes) and a hero pass-rate stat |
546
+ | `runDiff` | Since Last Run: new failures, fixed tests, and still-failing tests vs. the previous run on the same branch |
547
+ | `browserMatrix` | Cross-browser comparison: per-test outcomes side by side across the run's projects — divergent tests first, identical ones collapsed to a count. Appears only when the run has 2+ projects |
545
548
 
546
549
  #### Display modifiers: tune a section that's on
547
550
 
@@ -573,6 +576,8 @@ Omit `sections` entirely and each template renders exactly this (✓ = on):
573
576
  | `slowTests` | – | ✓ | ✓ |
574
577
  | `defectLog` | – | ✓ | – |
575
578
  | `briefBand` | – | – | ✓ |
579
+ | `runDiff` | ✓ | ✓ | ✓ |
580
+ | `browserMatrix` | – | ✓ | – |
576
581
  | `passRate` | ✓ | ✓ | ✓ |
577
582
  | `fullEnvironment` | – | ✓ | – |
578
583
  | `retries` | ✓ | ✓ | – |
@@ -583,7 +588,7 @@ Omit `sections` entirely and each template renders exactly this (✓ = on):
583
588
 
584
589
  - **`trend` needs `charts`.** The trend line, run-history, and flakiness table live inside the charts block, so `trend: true` does nothing with `charts: false` (it's coerced off). It also needs history data; keep the top-level `showTrend` option on.
585
590
  - **Inline failure detail needs `suiteBreakdown`.** `failureDeepDive` (failure detail), `failureAnalysis` (root-cause chip), and `slowTests` (`SLOW` badge) now render *inside* the suite breakdown, so they show nothing when `suiteBreakdown` is off. The chip (`failureAnalysis`) also needs `failureDeepDive`: it lives inside the failure detail, so it's coerced off without it.
586
- - **Data-gated sections**: `failureAnalysis`, `requirementsMatrix`, `slowTests`, `defectLog` render only when there's matching data (classified failures, tagged tests, a meaningful slow set, failures). Toggling them on with no data shows nothing.
591
+ - **Data-gated sections**: `failureAnalysis`, `requirementsMatrix`, `slowTests`, `defectLog` render only when there's matching data (classified failures, tagged tests, a meaningful slow set, failures). Toggling them on with no data shows nothing. `browserMatrix` is data-gated too: it needs 2+ projects in the run (multiple Playwright projects, or `pytest --browser` given twice), so single-browser runs never show it.
587
592
  - **Render-layer only.** `sections` controls what's drawn, never what's collected; the data switches stay separate: `showTrend` (history), `flakinessTopN` (flaky-table size), `failureAnalysis.enabled` (classifier), `includeScreenshots` (images).
588
593
  - **Custom templates.** `sections` applies to the built-in templates; a custom `templatePath` controls its own layout (every section available) and ignores `sections`. You'll get a warning if both are set.
589
594
 
package/dist/cli/index.js CHANGED
@@ -6518,6 +6518,7 @@ var init_sections = __esm({
6518
6518
  "defectLog",
6519
6519
  "briefBand",
6520
6520
  "runDiff",
6521
+ "browserMatrix",
6521
6522
  // display modifiers
6522
6523
  "passRate",
6523
6524
  "fullEnvironment",
@@ -6542,6 +6543,7 @@ var init_sections = __esm({
6542
6543
  defectLog: false,
6543
6544
  briefBand: false,
6544
6545
  runDiff: true,
6546
+ browserMatrix: false,
6545
6547
  passRate: true,
6546
6548
  fullEnvironment: false,
6547
6549
  retries: true,
@@ -6564,6 +6566,7 @@ var init_sections = __esm({
6564
6566
  defectLog: true,
6565
6567
  briefBand: false,
6566
6568
  runDiff: true,
6569
+ browserMatrix: true,
6567
6570
  passRate: true,
6568
6571
  fullEnvironment: true,
6569
6572
  retries: true,
@@ -6591,6 +6594,7 @@ var init_sections = __esm({
6591
6594
  defectLog: false,
6592
6595
  briefBand: true,
6593
6596
  runDiff: true,
6597
+ browserMatrix: false,
6594
6598
  passRate: true,
6595
6599
  fullEnvironment: false,
6596
6600
  retries: false,
package/dist/index.d.mts CHANGED
@@ -1,11 +1,11 @@
1
1
  import { Reporter, FullConfig, Suite, TestCase, TestResult, TestStep, FullResult } from '@playwright/test/reporter';
2
2
 
3
3
  /**
4
- * Canonical section catalog. 15 block toggles (section present/absent) followed
4
+ * Canonical section catalog. 16 block toggles (section present/absent) followed
5
5
  * by 5 display modifiers (tweak a section that is on). Single source of truth —
6
6
  * the Zod schema in src/config/schema.ts builds its flag shape from this array.
7
7
  */
8
- declare const SECTION_KEYS: readonly ["coverPage", "analysisOneliner", "releaseGate", "summary", "charts", "trend", "requirementsMatrix", "ciEnvironment", "suiteBreakdown", "failureDeepDive", "failureAnalysis", "slowTests", "defectLog", "briefBand", "runDiff", "passRate", "fullEnvironment", "retries", "fullFailures", "stackTraces"];
8
+ declare const SECTION_KEYS: readonly ["coverPage", "analysisOneliner", "releaseGate", "summary", "charts", "trend", "requirementsMatrix", "ciEnvironment", "suiteBreakdown", "failureDeepDive", "failureAnalysis", "slowTests", "defectLog", "briefBand", "runDiff", "browserMatrix", "passRate", "fullEnvironment", "retries", "fullFailures", "stackTraces"];
9
9
  type SectionKey = (typeof SECTION_KEYS)[number];
10
10
  /** A partial map of section flags — used both as the flat baseline and per-template. */
11
11
  type SectionFlags = Partial<Record<SectionKey, boolean>>;
@@ -406,18 +406,22 @@ interface ReporterOptions {
406
406
  * every chosen template; per-template keys (`minimal` / `detailed` /
407
407
  * `executive`) override the baseline for that template only.
408
408
  *
409
- * 20 keys total: 15 block toggles, 5 display modifiers.
409
+ * 21 keys total: 16 block toggles, 5 display modifiers.
410
410
  *
411
411
  * Block toggles: `coverPage`, `analysisOneliner`, `releaseGate`, `summary`,
412
412
  * `charts`, `trend`, `requirementsMatrix`, `ciEnvironment`, `suiteBreakdown`,
413
413
  * `failureDeepDive`, `failureAnalysis`, `slowTests`, `defectLog`, `briefBand`,
414
- * `runDiff`.
414
+ * `runDiff`, `browserMatrix`.
415
415
  * Display modifiers: `passRate`, `fullEnvironment`, `retries`, `fullFailures`,
416
416
  * `stackTraces`.
417
417
  *
418
418
  * `runDiff` shows new failures, fixed tests, and still-failing tests against
419
419
  * the previous run on the same branch; on by default in all three templates.
420
420
  *
421
+ * `browserMatrix` compares per-test outcomes across the run's projects
422
+ * (divergent tests first, identical ones collapsed to a count); on by default
423
+ * in `detailed` and self-hides when the run has fewer than two projects.
424
+ *
421
425
  * `trend` renders only when `charts` is also on and history data exists (`showTrend` enabled). Sections gated on data
422
426
  * (`failureAnalysis`, `requirementsMatrix`, `slowTests`, `defectLog`) appear
423
427
  * only when matching data exists — this option never changes data collection.
package/dist/index.d.ts CHANGED
@@ -1,11 +1,11 @@
1
1
  import { Reporter, FullConfig, Suite, TestCase, TestResult, TestStep, FullResult } from '@playwright/test/reporter';
2
2
 
3
3
  /**
4
- * Canonical section catalog. 15 block toggles (section present/absent) followed
4
+ * Canonical section catalog. 16 block toggles (section present/absent) followed
5
5
  * by 5 display modifiers (tweak a section that is on). Single source of truth —
6
6
  * the Zod schema in src/config/schema.ts builds its flag shape from this array.
7
7
  */
8
- declare const SECTION_KEYS: readonly ["coverPage", "analysisOneliner", "releaseGate", "summary", "charts", "trend", "requirementsMatrix", "ciEnvironment", "suiteBreakdown", "failureDeepDive", "failureAnalysis", "slowTests", "defectLog", "briefBand", "runDiff", "passRate", "fullEnvironment", "retries", "fullFailures", "stackTraces"];
8
+ declare const SECTION_KEYS: readonly ["coverPage", "analysisOneliner", "releaseGate", "summary", "charts", "trend", "requirementsMatrix", "ciEnvironment", "suiteBreakdown", "failureDeepDive", "failureAnalysis", "slowTests", "defectLog", "briefBand", "runDiff", "browserMatrix", "passRate", "fullEnvironment", "retries", "fullFailures", "stackTraces"];
9
9
  type SectionKey = (typeof SECTION_KEYS)[number];
10
10
  /** A partial map of section flags — used both as the flat baseline and per-template. */
11
11
  type SectionFlags = Partial<Record<SectionKey, boolean>>;
@@ -406,18 +406,22 @@ interface ReporterOptions {
406
406
  * every chosen template; per-template keys (`minimal` / `detailed` /
407
407
  * `executive`) override the baseline for that template only.
408
408
  *
409
- * 20 keys total: 15 block toggles, 5 display modifiers.
409
+ * 21 keys total: 16 block toggles, 5 display modifiers.
410
410
  *
411
411
  * Block toggles: `coverPage`, `analysisOneliner`, `releaseGate`, `summary`,
412
412
  * `charts`, `trend`, `requirementsMatrix`, `ciEnvironment`, `suiteBreakdown`,
413
413
  * `failureDeepDive`, `failureAnalysis`, `slowTests`, `defectLog`, `briefBand`,
414
- * `runDiff`.
414
+ * `runDiff`, `browserMatrix`.
415
415
  * Display modifiers: `passRate`, `fullEnvironment`, `retries`, `fullFailures`,
416
416
  * `stackTraces`.
417
417
  *
418
418
  * `runDiff` shows new failures, fixed tests, and still-failing tests against
419
419
  * the previous run on the same branch; on by default in all three templates.
420
420
  *
421
+ * `browserMatrix` compares per-test outcomes across the run's projects
422
+ * (divergent tests first, identical ones collapsed to a count); on by default
423
+ * in `detailed` and self-hides when the run has fewer than two projects.
424
+ *
421
425
  * `trend` renders only when `charts` is also on and history data exists (`showTrend` enabled). Sections gated on data
422
426
  * (`failureAnalysis`, `requirementsMatrix`, `slowTests`, `defectLog`) appear
423
427
  * only when matching data exists — this option never changes data collection.
package/dist/index.js CHANGED
@@ -10355,6 +10355,7 @@ var SECTION_KEYS = [
10355
10355
  "defectLog",
10356
10356
  "briefBand",
10357
10357
  "runDiff",
10358
+ "browserMatrix",
10358
10359
  // display modifiers
10359
10360
  "passRate",
10360
10361
  "fullEnvironment",
@@ -10379,6 +10380,7 @@ var SECTION_DEFAULTS = {
10379
10380
  defectLog: false,
10380
10381
  briefBand: false,
10381
10382
  runDiff: true,
10383
+ browserMatrix: false,
10382
10384
  passRate: true,
10383
10385
  fullEnvironment: false,
10384
10386
  retries: true,
@@ -10401,6 +10403,7 @@ var SECTION_DEFAULTS = {
10401
10403
  defectLog: true,
10402
10404
  briefBand: false,
10403
10405
  runDiff: true,
10406
+ browserMatrix: true,
10404
10407
  passRate: true,
10405
10408
  fullEnvironment: true,
10406
10409
  retries: true,
@@ -10428,6 +10431,7 @@ var SECTION_DEFAULTS = {
10428
10431
  defectLog: false,
10429
10432
  briefBand: true,
10430
10433
  runDiff: true,
10434
+ browserMatrix: false,
10431
10435
  passRate: true,
10432
10436
  fullEnvironment: false,
10433
10437
  retries: false,
@@ -11296,7 +11300,7 @@ var isTopLevel = (flat, f) => !f.ancestors.some((a) => {
11296
11300
  var testStepDepth = (flat, f) => f.ancestors.reduce((n, a) => n + (flat[a].category === "test.step" ? 1 : 0), 0);
11297
11301
  function flatten(steps) {
11298
11302
  const out = [];
11299
- const walk = (nodes, depth, ancestors) => {
11303
+ const walk2 = (nodes, depth, ancestors) => {
11300
11304
  for (const s of nodes ?? []) {
11301
11305
  const index = out.length;
11302
11306
  out.push({
@@ -11306,10 +11310,10 @@ function flatten(steps) {
11306
11310
  hasError: s.error != null,
11307
11311
  ancestors
11308
11312
  });
11309
- if (s.steps?.length) walk(s.steps, depth + 1, [...ancestors, index]);
11313
+ if (s.steps?.length) walk2(s.steps, depth + 1, [...ancestors, index]);
11310
11314
  }
11311
11315
  };
11312
- walk(steps ?? [], 0, []);
11316
+ walk2(steps ?? [], 0, []);
11313
11317
  return out;
11314
11318
  }
11315
11319
  function materialize(n, isFailing, depth) {
@@ -11917,7 +11921,7 @@ var ShardMerger = class {
11917
11921
  merge(filePaths) {
11918
11922
  if (filePaths.length === 0) throw new Error("shardResults: no valid JSON shard files found");
11919
11923
  const resolved = this.resolveShardPaths(filePaths);
11920
- const reports = this.loadReports(resolved);
11924
+ const reports = this.loadReports(resolved).map((r) => this.normalizeReport(r));
11921
11925
  const projects = this.mergeProjects(reports);
11922
11926
  const stats = this.mergeStats(reports, projects);
11923
11927
  const failures = sortBySeverity(this.extractFailures(reports));
@@ -11955,6 +11959,49 @@ var ShardMerger = class {
11955
11959
  return report;
11956
11960
  });
11957
11961
  }
11962
+ /**
11963
+ * Two top-level shapes reach the merger. Our language adapters (Python,
11964
+ * .NET) and the engine fixtures emit PROJECT wrapper suites (no `file`,
11965
+ * children are the file suites). Playwright's own --reporter=json emits
11966
+ * FILE suites at top level and hangs the project on each
11967
+ * spec.tests[].projectName instead. Everything downstream (mergeProjects,
11968
+ * extractFailures) assumes the wrapper shape, so real Playwright reports
11969
+ * are pivoted into it here: one wrapper per project, file trees filtered
11970
+ * to that project's tests. An unnamed project ('') renders as 'default',
11971
+ * matching the live reporter path.
11972
+ */
11973
+ normalizeReport(report) {
11974
+ if (report.suites.every((s) => s.file === void 0)) return report;
11975
+ const wrappers = this.collectProjectNames(report).map((name) => ({
11976
+ title: name === "" ? "default" : name,
11977
+ specs: [],
11978
+ suites: report.suites.map((fileSuite) => this.filterSuiteForProject(fileSuite, name)).filter((s) => s !== null)
11979
+ })).filter((w) => w.suites.length > 0);
11980
+ return { ...report, suites: wrappers };
11981
+ }
11982
+ /** Config order first, then any project discovered only on the tests. */
11983
+ collectProjectNames(report) {
11984
+ const names = [];
11985
+ const push = (n) => {
11986
+ if (!names.includes(n)) names.push(n);
11987
+ };
11988
+ for (const p of report.config?.projects ?? []) push(p.name ?? "");
11989
+ const walk2 = (suite) => {
11990
+ for (const spec of suite.specs ?? []) for (const t of spec.tests ?? []) push(t.projectName ?? "");
11991
+ for (const child of suite.suites ?? []) walk2(child);
11992
+ };
11993
+ for (const s of report.suites) walk2(s);
11994
+ return names.length > 0 ? names : [""];
11995
+ }
11996
+ filterSuiteForProject(suite, project) {
11997
+ const specs = (suite.specs ?? []).map((spec) => ({
11998
+ ...spec,
11999
+ tests: (spec.tests ?? []).filter((t) => (t.projectName ?? "") === project)
12000
+ })).filter((spec) => spec.tests.length > 0);
12001
+ const children = (suite.suites ?? []).map((child) => this.filterSuiteForProject(child, project)).filter((c) => c !== null);
12002
+ if (specs.length === 0 && children.length === 0) return null;
12003
+ return { ...suite, specs, suites: children };
12004
+ }
11958
12005
  mergeProjects(reports) {
11959
12006
  const projectMap = /* @__PURE__ */ new Map();
11960
12007
  for (const report of reports) {
@@ -13735,6 +13782,66 @@ function sourceTally(classified) {
13735
13782
  return ["rule", "retry", "local", "base"].filter((s) => counts.has(s)).map((s) => `${counts.get(s)} ${s}`).join(" / ");
13736
13783
  }
13737
13784
 
13785
+ // src/collector/BrowserMatrix.ts
13786
+ init_cjs_shims();
13787
+ var DEFAULT_ROW_CAP = 40;
13788
+ function buildBrowserMatrix(projects, cap = DEFAULT_ROW_CAP) {
13789
+ if (projects.length < 2) return void 0;
13790
+ let projectNames = projects.map((p, i) => p.name || `project ${i + 1}`);
13791
+ const byTest = /* @__PURE__ */ new Map();
13792
+ projects.forEach((project, column) => {
13793
+ walk(project.suites, [], (path15, test) => {
13794
+ const key = [...path15, test.title].join(" > ");
13795
+ let statuses = byTest.get(key);
13796
+ if (!statuses) {
13797
+ statuses = projectNames.map(() => null);
13798
+ byTest.set(key, statuses);
13799
+ }
13800
+ statuses[column] = test.status;
13801
+ });
13802
+ });
13803
+ const divergent = [];
13804
+ let identicalCount = 0;
13805
+ const contributing = /* @__PURE__ */ new Set();
13806
+ for (const [title, statuses] of byTest) {
13807
+ const present = statuses.filter((s) => s !== null);
13808
+ if (present.length < 2) continue;
13809
+ statuses.forEach((s, i) => {
13810
+ if (s !== null) contributing.add(i);
13811
+ });
13812
+ if (present.every((s) => s === present[0])) {
13813
+ identicalCount++;
13814
+ } else {
13815
+ divergent.push({ title, statuses });
13816
+ }
13817
+ }
13818
+ if (divergent.length === 0 && identicalCount === 0) return void 0;
13819
+ if (contributing.size < projectNames.length) {
13820
+ const keep = [...contributing].sort((a, b) => a - b);
13821
+ projectNames = keep.map((i) => projectNames[i]);
13822
+ for (const row of divergent) row.statuses = keep.map((i) => row.statuses[i]);
13823
+ }
13824
+ divergent.sort((a, b) => severityRank(b) - severityRank(a) || a.title.localeCompare(b.title));
13825
+ return {
13826
+ projects: projectNames,
13827
+ rows: divergent.slice(0, cap),
13828
+ divergentCount: divergent.length,
13829
+ identicalCount,
13830
+ truncated: divergent.length > cap,
13831
+ hasFailures: divergent.some((r) => severityRank(r) > 0)
13832
+ };
13833
+ }
13834
+ function severityRank(row) {
13835
+ return row.statuses.some((s) => s === "failed" || s === "timedOut") ? 1 : 0;
13836
+ }
13837
+ function walk(suites, path15, visit) {
13838
+ for (const suite of suites) {
13839
+ const next = [...path15, suite.title];
13840
+ for (const test of suite.tests) visit(next, test);
13841
+ walk(suite.suites, next, visit);
13842
+ }
13843
+ }
13844
+
13738
13845
  // src/redact/redact-report.ts
13739
13846
  init_cjs_shims();
13740
13847
  function redactReportData(data, r) {
@@ -13765,6 +13872,10 @@ function redactReportData(data, r) {
13765
13872
  data.briefSentence = rd(data.briefSentence);
13766
13873
  data.charts.suiteResults.forEach((s) => s.label = r.redactText(s.label));
13767
13874
  data.charts.flakinessTable?.forEach((row) => row.title = r.redactText(row.title));
13875
+ if (data.browserMatrix) {
13876
+ data.browserMatrix.projects = data.browserMatrix.projects.map((p) => r.redactText(p));
13877
+ data.browserMatrix.rows.forEach((row) => row.title = r.redactText(row.title));
13878
+ }
13768
13879
  data.environment.buildUrl = r.redactText(data.environment.buildUrl);
13769
13880
  }
13770
13881
  function redactStep(s, r) {
@@ -14505,7 +14616,8 @@ function buildReportData(collected, licenseInfo, hooks) {
14505
14616
  failures,
14506
14617
  environment,
14507
14618
  charts,
14508
- runDiff
14619
+ runDiff,
14620
+ browserMatrix: buildBrowserMatrix(projects)
14509
14621
  };
14510
14622
  }
14511
14623
  function resolveProjectNameFromPkg(cwd) {
@@ -14980,7 +15092,7 @@ var MAX_ERR_SNIPPET = 4096;
14980
15092
  var isHook = (c) => c === "hook" || c === "fixture";
14981
15093
  function flatten2(steps) {
14982
15094
  const out = [];
14983
- const walk = (nodes, depth, ancestors) => {
15095
+ const walk2 = (nodes, depth, ancestors) => {
14984
15096
  for (const s of nodes ?? []) {
14985
15097
  const index = out.length;
14986
15098
  out.push({
@@ -14990,10 +15102,10 @@ function flatten2(steps) {
14990
15102
  hasError: s.error != null,
14991
15103
  ancestors
14992
15104
  });
14993
- if (s.steps?.length) walk(s.steps, depth + 1, [...ancestors, index]);
15105
+ if (s.steps?.length) walk2(s.steps, depth + 1, [...ancestors, index]);
14994
15106
  }
14995
15107
  };
14996
- walk(steps ?? [], 0, []);
15108
+ walk2(steps ?? [], 0, []);
14997
15109
  return out;
14998
15110
  }
14999
15111
  var inHookSubtree2 = (flat, f) => isHook(f.category) || f.ancestors.some((a) => isHook(flat[a].category));
@@ -15257,7 +15369,7 @@ var PdfReporter = class {
15257
15369
  if (this.options.redact.enabled) {
15258
15370
  this.redactor = new Redactor(this.options.redact);
15259
15371
  }
15260
- this.version = true ? "0.28.0" : "0.x";
15372
+ this.version = true ? "0.29.0" : "0.x";
15261
15373
  logger.info(`@reportforge/playwright-pdf v${this.version} initialised`);
15262
15374
  this.licenseClient = new LicenseClient({
15263
15375
  licenseKey: this.options.licenseKey,
package/dist/index.mjs CHANGED
@@ -10356,6 +10356,7 @@ var SECTION_KEYS = [
10356
10356
  "defectLog",
10357
10357
  "briefBand",
10358
10358
  "runDiff",
10359
+ "browserMatrix",
10359
10360
  // display modifiers
10360
10361
  "passRate",
10361
10362
  "fullEnvironment",
@@ -10380,6 +10381,7 @@ var SECTION_DEFAULTS = {
10380
10381
  defectLog: false,
10381
10382
  briefBand: false,
10382
10383
  runDiff: true,
10384
+ browserMatrix: false,
10383
10385
  passRate: true,
10384
10386
  fullEnvironment: false,
10385
10387
  retries: true,
@@ -10402,6 +10404,7 @@ var SECTION_DEFAULTS = {
10402
10404
  defectLog: true,
10403
10405
  briefBand: false,
10404
10406
  runDiff: true,
10407
+ browserMatrix: true,
10405
10408
  passRate: true,
10406
10409
  fullEnvironment: true,
10407
10410
  retries: true,
@@ -10429,6 +10432,7 @@ var SECTION_DEFAULTS = {
10429
10432
  defectLog: false,
10430
10433
  briefBand: true,
10431
10434
  runDiff: true,
10435
+ browserMatrix: false,
10432
10436
  passRate: true,
10433
10437
  fullEnvironment: false,
10434
10438
  retries: false,
@@ -11297,7 +11301,7 @@ var isTopLevel = (flat, f) => !f.ancestors.some((a) => {
11297
11301
  var testStepDepth = (flat, f) => f.ancestors.reduce((n, a) => n + (flat[a].category === "test.step" ? 1 : 0), 0);
11298
11302
  function flatten(steps) {
11299
11303
  const out = [];
11300
- const walk = (nodes, depth, ancestors) => {
11304
+ const walk2 = (nodes, depth, ancestors) => {
11301
11305
  for (const s of nodes ?? []) {
11302
11306
  const index = out.length;
11303
11307
  out.push({
@@ -11307,10 +11311,10 @@ function flatten(steps) {
11307
11311
  hasError: s.error != null,
11308
11312
  ancestors
11309
11313
  });
11310
- if (s.steps?.length) walk(s.steps, depth + 1, [...ancestors, index]);
11314
+ if (s.steps?.length) walk2(s.steps, depth + 1, [...ancestors, index]);
11311
11315
  }
11312
11316
  };
11313
- walk(steps ?? [], 0, []);
11317
+ walk2(steps ?? [], 0, []);
11314
11318
  return out;
11315
11319
  }
11316
11320
  function materialize(n, isFailing, depth) {
@@ -11918,7 +11922,7 @@ var ShardMerger = class {
11918
11922
  merge(filePaths) {
11919
11923
  if (filePaths.length === 0) throw new Error("shardResults: no valid JSON shard files found");
11920
11924
  const resolved = this.resolveShardPaths(filePaths);
11921
- const reports = this.loadReports(resolved);
11925
+ const reports = this.loadReports(resolved).map((r) => this.normalizeReport(r));
11922
11926
  const projects = this.mergeProjects(reports);
11923
11927
  const stats = this.mergeStats(reports, projects);
11924
11928
  const failures = sortBySeverity(this.extractFailures(reports));
@@ -11956,6 +11960,49 @@ var ShardMerger = class {
11956
11960
  return report;
11957
11961
  });
11958
11962
  }
11963
+ /**
11964
+ * Two top-level shapes reach the merger. Our language adapters (Python,
11965
+ * .NET) and the engine fixtures emit PROJECT wrapper suites (no `file`,
11966
+ * children are the file suites). Playwright's own --reporter=json emits
11967
+ * FILE suites at top level and hangs the project on each
11968
+ * spec.tests[].projectName instead. Everything downstream (mergeProjects,
11969
+ * extractFailures) assumes the wrapper shape, so real Playwright reports
11970
+ * are pivoted into it here: one wrapper per project, file trees filtered
11971
+ * to that project's tests. An unnamed project ('') renders as 'default',
11972
+ * matching the live reporter path.
11973
+ */
11974
+ normalizeReport(report) {
11975
+ if (report.suites.every((s) => s.file === void 0)) return report;
11976
+ const wrappers = this.collectProjectNames(report).map((name) => ({
11977
+ title: name === "" ? "default" : name,
11978
+ specs: [],
11979
+ suites: report.suites.map((fileSuite) => this.filterSuiteForProject(fileSuite, name)).filter((s) => s !== null)
11980
+ })).filter((w) => w.suites.length > 0);
11981
+ return { ...report, suites: wrappers };
11982
+ }
11983
+ /** Config order first, then any project discovered only on the tests. */
11984
+ collectProjectNames(report) {
11985
+ const names = [];
11986
+ const push = (n) => {
11987
+ if (!names.includes(n)) names.push(n);
11988
+ };
11989
+ for (const p of report.config?.projects ?? []) push(p.name ?? "");
11990
+ const walk2 = (suite) => {
11991
+ for (const spec of suite.specs ?? []) for (const t of spec.tests ?? []) push(t.projectName ?? "");
11992
+ for (const child of suite.suites ?? []) walk2(child);
11993
+ };
11994
+ for (const s of report.suites) walk2(s);
11995
+ return names.length > 0 ? names : [""];
11996
+ }
11997
+ filterSuiteForProject(suite, project) {
11998
+ const specs = (suite.specs ?? []).map((spec) => ({
11999
+ ...spec,
12000
+ tests: (spec.tests ?? []).filter((t) => (t.projectName ?? "") === project)
12001
+ })).filter((spec) => spec.tests.length > 0);
12002
+ const children = (suite.suites ?? []).map((child) => this.filterSuiteForProject(child, project)).filter((c) => c !== null);
12003
+ if (specs.length === 0 && children.length === 0) return null;
12004
+ return { ...suite, specs, suites: children };
12005
+ }
11959
12006
  mergeProjects(reports) {
11960
12007
  const projectMap = /* @__PURE__ */ new Map();
11961
12008
  for (const report of reports) {
@@ -13736,6 +13783,66 @@ function sourceTally(classified) {
13736
13783
  return ["rule", "retry", "local", "base"].filter((s) => counts.has(s)).map((s) => `${counts.get(s)} ${s}`).join(" / ");
13737
13784
  }
13738
13785
 
13786
+ // src/collector/BrowserMatrix.ts
13787
+ init_esm_shims();
13788
+ var DEFAULT_ROW_CAP = 40;
13789
+ function buildBrowserMatrix(projects, cap = DEFAULT_ROW_CAP) {
13790
+ if (projects.length < 2) return void 0;
13791
+ let projectNames = projects.map((p, i) => p.name || `project ${i + 1}`);
13792
+ const byTest = /* @__PURE__ */ new Map();
13793
+ projects.forEach((project, column) => {
13794
+ walk(project.suites, [], (path16, test) => {
13795
+ const key = [...path16, test.title].join(" > ");
13796
+ let statuses = byTest.get(key);
13797
+ if (!statuses) {
13798
+ statuses = projectNames.map(() => null);
13799
+ byTest.set(key, statuses);
13800
+ }
13801
+ statuses[column] = test.status;
13802
+ });
13803
+ });
13804
+ const divergent = [];
13805
+ let identicalCount = 0;
13806
+ const contributing = /* @__PURE__ */ new Set();
13807
+ for (const [title, statuses] of byTest) {
13808
+ const present = statuses.filter((s) => s !== null);
13809
+ if (present.length < 2) continue;
13810
+ statuses.forEach((s, i) => {
13811
+ if (s !== null) contributing.add(i);
13812
+ });
13813
+ if (present.every((s) => s === present[0])) {
13814
+ identicalCount++;
13815
+ } else {
13816
+ divergent.push({ title, statuses });
13817
+ }
13818
+ }
13819
+ if (divergent.length === 0 && identicalCount === 0) return void 0;
13820
+ if (contributing.size < projectNames.length) {
13821
+ const keep = [...contributing].sort((a, b) => a - b);
13822
+ projectNames = keep.map((i) => projectNames[i]);
13823
+ for (const row of divergent) row.statuses = keep.map((i) => row.statuses[i]);
13824
+ }
13825
+ divergent.sort((a, b) => severityRank(b) - severityRank(a) || a.title.localeCompare(b.title));
13826
+ return {
13827
+ projects: projectNames,
13828
+ rows: divergent.slice(0, cap),
13829
+ divergentCount: divergent.length,
13830
+ identicalCount,
13831
+ truncated: divergent.length > cap,
13832
+ hasFailures: divergent.some((r) => severityRank(r) > 0)
13833
+ };
13834
+ }
13835
+ function severityRank(row) {
13836
+ return row.statuses.some((s) => s === "failed" || s === "timedOut") ? 1 : 0;
13837
+ }
13838
+ function walk(suites, path16, visit) {
13839
+ for (const suite of suites) {
13840
+ const next = [...path16, suite.title];
13841
+ for (const test of suite.tests) visit(next, test);
13842
+ walk(suite.suites, next, visit);
13843
+ }
13844
+ }
13845
+
13739
13846
  // src/redact/redact-report.ts
13740
13847
  init_esm_shims();
13741
13848
  function redactReportData(data, r) {
@@ -13766,6 +13873,10 @@ function redactReportData(data, r) {
13766
13873
  data.briefSentence = rd(data.briefSentence);
13767
13874
  data.charts.suiteResults.forEach((s) => s.label = r.redactText(s.label));
13768
13875
  data.charts.flakinessTable?.forEach((row) => row.title = r.redactText(row.title));
13876
+ if (data.browserMatrix) {
13877
+ data.browserMatrix.projects = data.browserMatrix.projects.map((p) => r.redactText(p));
13878
+ data.browserMatrix.rows.forEach((row) => row.title = r.redactText(row.title));
13879
+ }
13769
13880
  data.environment.buildUrl = r.redactText(data.environment.buildUrl);
13770
13881
  }
13771
13882
  function redactStep(s, r) {
@@ -14506,7 +14617,8 @@ function buildReportData(collected, licenseInfo, hooks) {
14506
14617
  failures,
14507
14618
  environment,
14508
14619
  charts,
14509
- runDiff
14620
+ runDiff,
14621
+ browserMatrix: buildBrowserMatrix(projects)
14510
14622
  };
14511
14623
  }
14512
14624
  function resolveProjectNameFromPkg(cwd) {
@@ -14981,7 +15093,7 @@ var MAX_ERR_SNIPPET = 4096;
14981
15093
  var isHook = (c) => c === "hook" || c === "fixture";
14982
15094
  function flatten2(steps) {
14983
15095
  const out = [];
14984
- const walk = (nodes, depth, ancestors) => {
15096
+ const walk2 = (nodes, depth, ancestors) => {
14985
15097
  for (const s of nodes ?? []) {
14986
15098
  const index = out.length;
14987
15099
  out.push({
@@ -14991,10 +15103,10 @@ function flatten2(steps) {
14991
15103
  hasError: s.error != null,
14992
15104
  ancestors
14993
15105
  });
14994
- if (s.steps?.length) walk(s.steps, depth + 1, [...ancestors, index]);
15106
+ if (s.steps?.length) walk2(s.steps, depth + 1, [...ancestors, index]);
14995
15107
  }
14996
15108
  };
14997
- walk(steps ?? [], 0, []);
15109
+ walk2(steps ?? [], 0, []);
14998
15110
  return out;
14999
15111
  }
15000
15112
  var inHookSubtree2 = (flat, f) => isHook(f.category) || f.ancestors.some((a) => isHook(flat[a].category));
@@ -15258,7 +15370,7 @@ var PdfReporter = class {
15258
15370
  if (this.options.redact.enabled) {
15259
15371
  this.redactor = new Redactor(this.options.redact);
15260
15372
  }
15261
- this.version = true ? "0.28.0" : "0.x";
15373
+ this.version = true ? "0.29.0" : "0.x";
15262
15374
  logger.info(`@reportforge/playwright-pdf v${this.version} initialised`);
15263
15375
  this.licenseClient = new LicenseClient({
15264
15376
  licenseKey: this.options.licenseKey,
@@ -26,6 +26,8 @@
26
26
 
27
27
  {{#if sections.runDiff}}{{> run-diff variant='full'}}{{/if}}
28
28
 
29
+ {{#if sections.browserMatrix}}{{> browser-matrix}}{{/if}}
30
+
29
31
  {{#if sections.requirementsMatrix}}{{> requirements-matrix }}{{/if}}
30
32
 
31
33
  {{#if sections.ciEnvironment}}{{> ci-environment options=(obj showFullEnvironment=sections.fullEnvironment) }}{{/if}}
@@ -26,6 +26,8 @@
26
26
 
27
27
  {{#if sections.charts}}{{> charts options=(obj showCharts=true showTrend=sections.trend heroTrend=true) }}{{/if}}
28
28
 
29
+ {{#if sections.browserMatrix}}{{> browser-matrix}}{{/if}}
30
+
29
31
  {{#if sections.requirementsMatrix}}{{> requirements-matrix }}{{/if}}
30
32
 
31
33
  {{#if sections.defectLog}}{{> defect-log }}{{/if}}
@@ -26,6 +26,8 @@
26
26
 
27
27
  {{#if sections.charts}}{{> charts options=(obj showCharts=true showTrend=sections.trend) }}{{/if}}
28
28
 
29
+ {{#if sections.browserMatrix}}{{> browser-matrix}}{{/if}}
30
+
29
31
  {{#if sections.requirementsMatrix}}{{> requirements-matrix }}{{/if}}
30
32
 
31
33
  {{#if sections.suiteBreakdown}}{{> suite-breakdown options=(obj showRetries=sections.retries showFullFailures=sections.fullFailures showStackTraces=sections.stackTraces) }}{{/if}}
@@ -0,0 +1,34 @@
1
+ {{#if browserMatrix}}
2
+ <div class="section section--browser-matrix">
3
+ <h2>Cross-Browser Comparison</h2>
4
+ {{#if browserMatrix.rows.length}}
5
+ <p class="browser-matrix__intro">{{browserMatrix.divergentCount}} {{pluralise browserMatrix.divergentCount "test behaves" "tests behave"}} differently across the run's {{browserMatrix.projects.length}} projects.{{#if browserMatrix.hasFailures}} Failures first.{{/if}}</p>
6
+ <table class="browser-matrix__table">
7
+ <thead>
8
+ <tr>
9
+ <th>Test</th>
10
+ {{#each browserMatrix.projects}}<th class="browser-matrix__col">{{this}}</th>{{/each}}
11
+ </tr>
12
+ </thead>
13
+ <tbody>
14
+ {{#each browserMatrix.rows}}
15
+ <tr class="no-break">
16
+ <td class="browser-matrix__title">{{title}}</td>
17
+ {{#each statuses}}
18
+ <td class="browser-matrix__cell">{{#if this}}{{statusBadge this}}{{else}}<span class="browser-matrix__na">n/a</span>{{/if}}</td>
19
+ {{/each}}
20
+ </tr>
21
+ {{/each}}
22
+ </tbody>
23
+ </table>
24
+ {{#if browserMatrix.truncated}}
25
+ <div class="browser-matrix__note">+ {{subtract browserMatrix.divergentCount browserMatrix.rows.length}} more divergent {{pluralise (subtract browserMatrix.divergentCount browserMatrix.rows.length) "test" "tests"}} not shown</div>
26
+ {{/if}}
27
+ {{#if browserMatrix.identicalCount}}
28
+ <div class="browser-matrix__note">{{browserMatrix.identicalCount}} {{pluralise browserMatrix.identicalCount "test behaves" "tests behave"}} the same in every project.</div>
29
+ {{/if}}
30
+ {{else}}
31
+ <p class="browser-matrix__intro">All {{browserMatrix.identicalCount}} comparable {{pluralise browserMatrix.identicalCount "test behaves" "tests behave"}} the same across the run's {{browserMatrix.projects.length}} projects.</p>
32
+ {{/if}}
33
+ </div>
34
+ {{/if}}
@@ -954,3 +954,27 @@ code {
954
954
  /* The aggregate failure-analysis cluster section was retired — root causes now
955
955
  render as inline per-test chips in the suite breakdown (.root-cause-chip above).
956
956
  The .analysis-oneliner one-liner lives further up and is still used by executive. */
957
+
958
+ /* ── Cross-browser comparison matrix (sections.browserMatrix) ───────────── */
959
+ /* Divergent tests only; identical tests collapse into the note line. Cells
960
+ reuse the .badge--* status palette so the grid reads like the rest of the
961
+ report. Lives here (not a template css) because the section is toggle-able
962
+ across all three layouts. */
963
+ .browser-matrix__intro {
964
+ font-size: 10px; color: var(--text-3); margin-bottom: 12px;
965
+ }
966
+ .browser-matrix__table th.browser-matrix__col {
967
+ width: 88px; text-align: center; text-transform: capitalize;
968
+ }
969
+ .browser-matrix__title {
970
+ font-size: 9.5px; color: var(--text-1); word-break: break-word;
971
+ }
972
+ .browser-matrix__cell { text-align: center; }
973
+ .browser-matrix__cell .badge { min-width: 0; font-size: 7.5px; }
974
+ .browser-matrix__na {
975
+ font-family: 'JetBrains Mono', monospace;
976
+ font-size: 8.5px; color: var(--text-3);
977
+ }
978
+ .browser-matrix__note {
979
+ font-size: 9px; color: var(--text-3); margin-top: 6px;
980
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reportforge/playwright-pdf",
3
- "version": "0.28.0",
3
+ "version": "0.29.0",
4
4
  "description": "Playwright Test reporter that generates designed PDF reports: minimal, detailed, and executive templates with CI/CD integrations",
5
5
  "license": "Elastic-2.0",
6
6
  "author": "ReportForge",