@qartisan/playwright-loupe 0.0.1 → 0.0.3

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/CHANGELOG.md CHANGED
@@ -4,7 +4,46 @@ All notable changes to this project are documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Before 1.0, minor versions may contain breaking changes. The JSON report has its own version (`schemaVersion`), see [`docs/schema/v1.md`](docs/schema/v1.md#versioning-policy).
6
6
 
7
- ## [Unreleased]
7
+ ## [0.0.3] - 2026-10-16
8
+
9
+ ### Added
10
+
11
+ - **HTML report application shell.** The renderer is rebuilt with Preact and `@preact/signals`, still one self-contained `index.html` that works from `file://` (both libraries are bundled into it: the package keeps zero runtime dependencies).
12
+ - Header with the run summary: status, counts per outcome, duration, branch, commit and environment. A partial run shows a banner with the reasons it was detected.
13
+ - Collapsible sidebar with a test tree: projects → files → describe blocks → tests, single-child levels merged, a status icon per test (passed, failed, flaky, skipped) and failure counts per node. Keyboard navigation (arrows, Home / End, Enter), the current test highlighted and revealed. Collapsed nodes are not rendered, so large suites stay responsive.
14
+ - Hash routes that work from `file://`: `#/` (overview), `#/errors`, `#/health`, `#/group/<signature>`, `#/test/<id>`. The URL is the single source of navigation state.
15
+ - Light and dark themes: the system theme by default, or the `theme` option; the viewer's choice is remembered in the browser when storage is available.
16
+ - The test page is a placeholder for now.
17
+ - **Dashboard, in three views** reached from fixed sidebar entries. Widgets with nothing to show are hidden, except the verdict.
18
+ - **Overview** (`#/`): the verdict (failed, flaky or passed, a stacked bar of the outcomes with their counts, pass rate and duration; a run with run errors is never green), run errors when there are any, compact error categories (one bar per category, each opening that category in the Errors view, `#/errors/category/<name>`), and project cards (counts, pass rate, test time; a click reveals the project in the sidebar).
19
+ - **Pass rate**, one definition everywhere: passed / (total − skipped), labeled "pass rate", with a tooltip saying skipped tests are excluded.
20
+ - **Errors** (`#/errors`): run errors, every error group (most failed tests first, then most flaky), and the categories in detail, each expanding to its groups. Error groups are listed here only. The sidebar entry shows a warning marker and the count when the run has run errors. The former `#/run-errors` route redirects here.
21
+ - **Suite health** (`#/health`): one widget per dimension. Up to 8 values, one stacked bar per value (passed / failed / flaky / skipped) with the total; more values, a grid of tiles colored by worst status with its icon, problem values first, details on hover and keyboard focus, and a summary line (`38 values · 36 all passed · 2 with failures`). The exact table expands from every dimension widget. Tag hygiene: values outside the declared ones and values differing only by case (`API` / `api`) are flagged, never merged; a hint points to `values` when a dimension declares none. Then the ten slowest tests, the flaky tests with the error of their failing attempt, and the skipped tests with their reason.
22
+ - An error group row shows its category, its failed / flaky split, the first affected test (`+N others`) and the line that tells the error apart: the `Locator:` line or the HTTP response status rather than a generic `expect(locator).toBeVisible() failed`.
23
+ - **Error group pages** (`#/group/<signature>`): category, signature, failed / flaky split, a full sample message and every affected test with the attempts where the error occurred. Groups are computed in the renderer from the existing report data.
24
+ - **Visual identity.** New light and dark palettes, "Loupe" wordmark in the header, a "Loupe — a reporter for Playwright" footer with the version, and DM Sans (regular and semibold, latin) embedded in the HTML so the report keeps working offline. The font is under the SIL Open Font License, shipped in `licenses/DM-Sans-OFL.txt`. Contrast (4.5:1 for text, 3:1 for borders and icons) is checked by the test suite in both themes. The HTML report is about 52 kB gzip, 29 kB of which are the fonts.
25
+ - **Category colors from a palette.** `categories.rules[].color` now also accepts a palette name: `blue`, `cyan`, `teal`, `violet`, `magenta`, `orange`, `brown`, `gold` (see [Category palette](docs/config.md#category-palette)). An unknown name is a config error listing the valid names. `#rrggbb` colors still work; the HTML report derives light and dark shades from them that meet the contrast rules.
26
+
27
+ ### Changed
28
+
29
+ - **Error signatures change again: failures are grouped differently from 0.0.2.** The signature now uses only the stable part of Playwright messages. A strict mode violation loses its element list and count (`resolved to <n> elements`), so a test and its retry get the same signature when the page gained an item between attempts (`.first()` vs `.nth(1)`, `li:nth-child(13)` vs `14`). A locator assertion keeps only its `Error:` lines, `Locator:`, and `Expected…:` / `Received…:` lines: the timeout and screenshot pixel counts no longer count. Different locators, actions or expected values still give different signatures. The call log, stack and code frame were already left out. The JSON shape is unchanged: `schemaVersion` stays `1`. See [Error signature](docs/schema/v1.md#error-signature).
30
+ - **Built-in category colors changed** to the new palette, without reds or greens and with no two alike (they were near-duplicate indigos, and Assertion was red like a failure): Ambiguous locator violet `#8656b7`, Locator not found magenta `#ad4681`, Rate limited teal `#04837c`, Server error orange `#ab5803`, Network cyan `#0a7da1`, Assertion blue `#466cc8`, Timeout brown `#745f43`, Uncategorized `#6b6b6b`. New values, same shape: `schemaVersion` stays `1`. `CategoryDefinition.color` is now documented as a reference color that renderers may adapt per theme.
31
+ - **Categories without a configured color** take, in config rule order, the first palette color no other category of the report uses (gold, never used by a built-in category, comes last). Colors used to be assigned over every rule, so with the built-in categories present a configured category could share a color with one shown next to it. The same config with the same categories present always gives the same colors; set `color` for a color that never changes.
32
+ - **New built-in error category: Ambiguous locator**, evaluated first, before "Locator not found". It matches Playwright's `strict mode violation` (a locator resolving to several elements), which was "Uncategorized" or "Assertion" before. See [Error categories](docs/schema/v1.md#error-categories).
33
+
34
+ ### Fixed
35
+
36
+ - **Run scope with `--no-deps`.** Playwright keeps `project.dependencies` in the config reporters get, so with `--project x --no-deps` the dependencies of `x` counted as running and the run could be reported `full` while a configured project was left out. They no longer count: the run is `partial` with the `project-filter` reason.
37
+ - **Run scope with `default: false` projects** (Playwright 1.64+). The reference for a full run is now the set of default projects: a plain run is `full` even though the non-default projects did not run, and `--project nightly` alone is `partial` because the default projects are missing. See [Default projects](docs/schema/v1.md#default-projects).
38
+ - **Run scope with several `--project` values.** `--project a b` selects two projects, as in Playwright; `b` was taken for a file filter and the run reported a `file-filter` reason.
39
+
40
+ ## [0.0.2] - 2026-10-15
41
+
42
+ ### Changed
43
+
44
+ - **Error signatures change: failures are grouped differently from 0.0.1.** Normalization now also replaces e-mail addresses with `<email>` and cuid-like and nanoid-like identifiers (20+ characters mixing letters and digits) with `<id>`, so the same failure for different users or records gets one signature. A signature computed by 0.0.1 for a message containing such values no longer matches. The JSON shape is unchanged: `schemaVersion` stays `1`. See [Error signature](docs/schema/v1.md#error-signature).
45
+ - **New built-in error categories**, evaluated after "Locator not found" and before "Network", "Assertion" and "Timeout": **Rate limited** (`Too Many Requests`, `rate limit`, a 429 in an HTTP context such as `status 429`, `HTTP 429` or `Response status: 429`) and **Server error** (a 500–599 status in the same contexts, or with its reason phrase such as `502 Bad Gateway`). Errors from `failOnStatusCode` and failed `expect(response).toBeOK()` that were "Uncategorized" or "Assertion" now land in these categories. A bare number (`429ms`, `Received: 500`) does not match. See [Error categories](docs/schema/v1.md#error-categories).
46
+ - The package description now lists only what exists today.
8
47
 
9
48
  ## [0.0.1] - 2026-10-13
10
49
 
package/README.md CHANGED
@@ -1,8 +1,10 @@
1
1
  # playwright-loupe
2
- Loupe — a clear, insightful HTML reporter for Playwright. Project overview, history, flakiness and error categories, using native Playwright APIs only.
2
+ Loupe — an HTML reporter for Playwright: a dashboard that shows what failed and why, error groups by normalized signature, tests grouped by project, file and describe block, and dimensions from tags and annotations. Native Playwright APIs only.
3
3
 
4
4
  > **Status: work in progress — the JSON schema will change before 1.0.**
5
5
 
6
+ ![Loupe overview](https://raw.githubusercontent.com/qartisan/playwright-loupe/main/docs/images/overview.png)
7
+
6
8
  Loupe is a reporter for Playwright, not affiliated with Microsoft.
7
9
 
8
10
  ## Installation
@@ -29,10 +31,24 @@ export default defineConfig({
29
31
 
30
32
  After a run, open `loupe-report/index.html`. The raw data is in `loupe-report/report.json`, with attachments in `loupe-report/data/`. Its format is documented in [`docs/schema/v1.md`](docs/schema/v1.md), with a [JSON Schema](docs/schema/report-v1.schema.json) and TypeScript types (`import type { LoupeReport } from '@qartisan/playwright-loupe'`).
31
33
 
32
- Loupe requires `@playwright/test` 1.52 or later.
33
-
34
34
  A run that broke outside its tests (a spec file that fails to import, a failing global setup) is reported in `run.errors`, even when no test ran.
35
35
 
36
+ ## The report
37
+
38
+ The report is a single self-contained HTML file. It works offline, straight from `file://`, in light and dark themes.
39
+
40
+ - **Overview:** verdict with pass rate, run errors first when the run broke outside its tests, error categories, project cards.
41
+ - **Errors:** every error group, built from normalized error signatures, with the failed / flaky split and links to the affected tests.
42
+ - **Suite health:** dimensions from tags and annotations (with hints for unknown or inconsistent values), slowest, skipped and flaky tests.
43
+ - **Sidebar:** projects, files, `describe` blocks and tests, with status icons, failure counts and keyboard navigation.
44
+
45
+ ### Coming next
46
+
47
+ - Search and filters (name, suite, status, tag), kept in the URL.
48
+ - Test detail: error, steps, and attachments per attempt (screenshots, video, trace), copyable rerun command and AI prompt.
49
+
50
+ History and flakiness tracking are planned for a later phase; the JSON model is already prepared for them.
51
+
36
52
  ## Configuration
37
53
 
38
54
  Put a `loupe.config.ts` next to `playwright.config.ts`:
@@ -44,7 +60,7 @@ import { defineConfig } from '@qartisan/playwright-loupe';
44
60
  export default defineConfig({
45
61
  title: 'My suite',
46
62
  categories: {
47
- rules: [{ name: 'Infra', match: [/ECONNRESET/, /ECONNREFUSED/] }],
63
+ rules: [{ name: 'Infra', match: [/ECONNRESET/, /ECONNREFUSED/], color: 'teal' }],
48
64
  },
49
65
  dimensions: {
50
66
  definitions: [
@@ -78,7 +94,7 @@ Node.js 22.12 is the minimum because ES module config files are loaded with `req
78
94
  | `npm run typecheck` | Type-checks the core, tests, fixtures and renderer. |
79
95
  | `npm test` | Builds, then runs the Vitest unit tests and the integration tests (fixture suite, and a sharded run merged with `merge-reports`). |
80
96
  | `npm run fixtures` | Builds, then runs the fixture suite into `loupe-report/`. Its failures are intentional, see [`fixtures/README.md`](fixtures/README.md). |
81
- | `npm run sample:update` | Runs the fixture suite and copies its report to `src/renderer/dev/sample.json`. |
82
- | `npm run dev:renderer` | Vite dev server for the renderer, fed with `src/renderer/dev/sample.json`, with hot reload. |
97
+ | `npm run sample:update` | Builds, then runs the fixture suite with `fixtures/sample.config.ts` and writes its report to `src/renderer/dev/sample.json`: failures, flaky and skipped tests, a partial run and a run error. |
98
+ | `npm run dev:renderer` | Vite dev server for the renderer, fed with `src/renderer/dev/sample.json`, with hot reload. No Playwright run needed for UI work. |
83
99
 
84
100
  Tests that drive a browser need Chromium: `npx playwright install chromium`.
@@ -1,4 +1,5 @@
1
1
  import path from 'node:path';
2
+ import { paletteReference } from '../shared/palette.js';
2
3
  import { validateConfig } from './validate.js';
3
4
  export const DEFAULT_OUTPUT_FOLDER = 'loupe-report';
4
5
  export const DEFAULT_TITLE = 'Loupe report';
@@ -68,7 +69,8 @@ function resolveRules(config) {
68
69
  name: rule.name,
69
70
  match: (Array.isArray(rule.match) ? rule.match : [rule.match]).map(stateless),
70
71
  on: rule.on ?? 'all',
71
- color: rule.color ?? null,
72
+ // A palette name becomes its reference color: the report only carries #rrggbb colors.
73
+ color: rule.color === undefined ? null : rule.color.startsWith('#') ? rule.color : paletteReference(rule.color),
72
74
  }));
73
75
  }
74
76
  function resolveDimensions(config) {
@@ -17,7 +17,11 @@ export interface CategoryRuleConfig {
17
17
  match: RegExp | RegExp[];
18
18
  /** What the patterns are tested against. Default: `'all'`. */
19
19
  on?: CategoryTarget;
20
- /** `#rrggbb`. Default: a color from Loupe's palette. */
20
+ /**
21
+ * `#rrggbb`, or the name of a palette color: `blue`, `cyan`, `teal`,
22
+ * `violet`, `magenta`, `orange`, `brown`, `gold`. Default: the first
23
+ * palette color no other category of the report uses, in rule order.
24
+ */
21
25
  color?: string;
22
26
  }
23
27
  export interface CategoriesConfig {
@@ -75,6 +79,7 @@ export interface ResolvedCategoryRule {
75
79
  name: string;
76
80
  match: RegExp[];
77
81
  on: CategoryTarget;
82
+ /** `#rrggbb` (palette names are resolved to their reference color); null when not configured. */
78
83
  color: string | null;
79
84
  }
80
85
  export type ResolvedDimension = {
@@ -1,3 +1,4 @@
1
+ import { PALETTE_NAMES } from '../shared/palette.js';
1
2
  import { CATEGORY_MODES, CATEGORY_TARGETS, SCREENSHOT_VIEWS, THEMES } from './types.js';
2
3
  const COLOR = /^#[0-9a-fA-F]{6}$/;
3
4
  /** What a tag prefix cannot contain: `@layer:api` has the prefix `layer`. */
@@ -102,10 +103,16 @@ function boolean(ctx) {
102
103
  fail(ctx, path, 'a boolean', value, errors);
103
104
  };
104
105
  }
106
+ /** A `#rrggbb` color or the name of a palette color (`teal`). */
105
107
  function color(ctx) {
106
108
  return (value, path, errors) => {
107
- if (typeof value !== 'string' || !COLOR.test(value))
108
- fail(ctx, path, 'a #rrggbb color', value, errors);
109
+ if (typeof value === 'string' && (COLOR.test(value) || PALETTE_NAMES.includes(value)))
110
+ return;
111
+ const expected = `a #rrggbb color or a palette color (${PALETTE_NAMES.map((name) => `"${name}"`).join(', ')})`;
112
+ const suggestion = typeof value === 'string' && !value.startsWith('#') ? suggestKey(value, PALETTE_NAMES) : null;
113
+ fail(ctx, path, expected, value, errors);
114
+ if (suggestion)
115
+ errors[errors.length - 1] += `. Did you mean "${suggestion}"?`;
109
116
  };
110
117
  }
111
118
  function regexps(ctx) {
@@ -87,6 +87,26 @@ function toAnnotation(annotation) {
87
87
  function isDefaultGrep(grep) {
88
88
  return grep instanceof RegExp && grep.source === '.*' && grep.flags === '';
89
89
  }
90
+ /**
91
+ * Names of the projects with `default: false` (Playwright 1.64+). Only
92
+ * Playwright's internal config has the flag, under a private symbol; undefined
93
+ * when it is missing (older versions, merge-reports) or has another shape.
94
+ */
95
+ function nonDefaultProjects(config) {
96
+ const symbol = Object.getOwnPropertySymbols(config).find((candidate) => candidate.description === 'configInternalSymbol');
97
+ const internal = symbol ? config[symbol] : undefined;
98
+ const projects = internal?.projects;
99
+ if (!Array.isArray(projects))
100
+ return undefined;
101
+ const names = [];
102
+ for (const project of projects) {
103
+ if (typeof project.default !== 'boolean' || typeof project.project?.name !== 'string')
104
+ return undefined;
105
+ if (!project.default)
106
+ names.push(project.project.name);
107
+ }
108
+ return names;
109
+ }
90
110
  /** `test.only` / `describe.only` leave Playwright's internal `_only` flag on the node or an ancestor. */
91
111
  function isFocused(test) {
92
112
  for (let node = test; node; node = node.parent) {
@@ -263,6 +283,7 @@ export async function buildReport(config, suite, result, options) {
263
283
  teardown: project.teardown,
264
284
  })),
265
285
  filteredProjects: extra.filteredProjects?.map((project) => project.name),
286
+ nonDefaultProjects: nonDefaultProjects(config),
266
287
  runProjects: projects.map((project) => project.name),
267
288
  only: suite.allTests().some(isFocused),
268
289
  });
@@ -15,17 +15,16 @@ export interface ErrorText {
15
15
  type Target = Exclude<CategoryTarget, 'all'>;
16
16
  export interface CategoryRule {
17
17
  name: string;
18
- /** Null: a color is picked from the palette. */
18
+ /** `#rrggbb`. Null: a color is picked from the palette. */
19
19
  color: string | null;
20
20
  match: RegExp[];
21
21
  targets: readonly Target[];
22
22
  }
23
- export declare const UNCATEGORIZED_COLOR = "#6b7280";
23
+ export declare const UNCATEGORIZED_COLOR: string;
24
24
  export declare const BUILT_IN_RULES: readonly CategoryRule[];
25
25
  /**
26
- * Colors for categories without one, assigned in rule order. Distinct from the
27
- * built-in and uncategorized colors; colors already taken by another category
28
- * are skipped, and the palette wraps around when exhausted.
26
+ * Colors for categories without one: the reference colors of the palette, in
27
+ * palette order (see `src/shared/palette.ts`).
29
28
  */
30
29
  export declare const PALETTE: readonly string[];
31
30
  export interface CategoriesOptions {
@@ -1,18 +1,54 @@
1
+ import { NEUTRAL_ENTRY, PALETTE_ENTRIES, paletteReference } from '../shared/palette.js';
1
2
  const ALL_TARGETS = ['message', 'stack', 'step'];
2
- export const UNCATEGORIZED_COLOR = '#6b7280';
3
+ export const UNCATEGORIZED_COLOR = NEUTRAL_ENTRY.reference;
4
+ /**
5
+ * An HTTP status in context: `status 429`, `status code: 429`, `"status": 429`,
6
+ * `statusCode=429`, `HTTP 429`, `HTTP/1.1 429`, `Response status: 429`, or
7
+ * `← 429`, the response line of Playwright's fetch log.
8
+ */
9
+ function httpStatus(code) {
10
+ return new RegExp(`(?:\\b(?:status(?:[ _]?code)?["']?\\s*[:=]?|HTTP(?:/\\d(?:\\.\\d)?)?:?)|\u2190)\\s*${code}(?!\\w|\\.\\d)`, 'i');
11
+ }
3
12
  export const BUILT_IN_RULES = [
13
+ {
14
+ // Before "Locator not found", "Assertion" and "Timeout": a locator matching
15
+ // several elements fails strict mode, through an action or an expect.
16
+ name: 'Ambiguous locator',
17
+ color: paletteReference('violet'),
18
+ match: [/\bstrict mode violation\b/i],
19
+ targets: ['message', 'stack'],
20
+ },
4
21
  {
5
22
  // Before "Assertion" and "Timeout": a missing element surfaces as a failed
6
23
  // or timed-out expect/action, and "not found" is the useful diagnosis.
7
24
  name: 'Locator not found',
8
- color: '#7c3aed',
25
+ color: paletteReference('magenta'),
9
26
  match: [/element\(s\) not found/i, /resolved to 0 elements/i],
10
27
  targets: ['message', 'stack'],
11
28
  },
29
+ {
30
+ // Before "Network", "Assertion" and "Timeout": the status is the diagnosis,
31
+ // whether it surfaces through `failOnStatusCode`, `toBeOK()` or a thrown
32
+ // error. A bare 429 is not enough (`429ms`, `Received: 429`): it needs an
33
+ // HTTP context.
34
+ name: 'Rate limited',
35
+ color: paletteReference('teal'),
36
+ match: [/\bToo Many Requests\b/i, /\brate[- ]?limit/i, httpStatus('429')],
37
+ targets: ['message', 'stack'],
38
+ },
39
+ {
40
+ name: 'Server error',
41
+ color: paletteReference('orange'),
42
+ match: [
43
+ httpStatus('5\\d\\d'),
44
+ /\b5\d\d (?:Internal Server Error|Not Implemented|Bad Gateway|Service Unavailable|Gateway Time-?out|HTTP Version Not Supported)\b/i,
45
+ ],
46
+ targets: ['message', 'stack'],
47
+ },
12
48
  {
13
49
  // Before "Timeout" so connection timeouts (ETIMEDOUT) count as network.
14
50
  name: 'Network',
15
- color: '#0891b2',
51
+ color: paletteReference('cyan'),
16
52
  match: [
17
53
  /\bnet::ERR_[A-Z_]+/,
18
54
  /\bNS_ERROR_[A-Z_]+/,
@@ -26,34 +62,22 @@ export const BUILT_IN_RULES = [
26
62
  // Before "Timeout": a retrying expect that never matched reports its own
27
63
  // timeout but is an assertion failure.
28
64
  name: 'Assertion',
29
- color: '#dc2626',
65
+ color: paletteReference('blue'),
30
66
  match: [/\bexpect\(/, /^\s*Expected\b.*:/m, /\bAssertionError\b/],
31
67
  targets: ['message', 'stack'],
32
68
  },
33
69
  {
34
70
  name: 'Timeout',
35
- color: '#d97706',
71
+ color: paletteReference('brown'),
36
72
  match: [/\bTest timeout of \d+ms exceeded/i, /\bTimeout \d+ms exceeded/i, /\btimed out\b/i],
37
73
  targets: ['message', 'stack'],
38
74
  },
39
75
  ];
40
76
  /**
41
- * Colors for categories without one, assigned in rule order. Distinct from the
42
- * built-in and uncategorized colors; colors already taken by another category
43
- * are skipped, and the palette wraps around when exhausted.
77
+ * Colors for categories without one: the reference colors of the palette, in
78
+ * palette order (see `src/shared/palette.ts`).
44
79
  */
45
- export const PALETTE = [
46
- '#2563eb',
47
- '#16a34a',
48
- '#db2777',
49
- '#ca8a04',
50
- '#0d9488',
51
- '#9333ea',
52
- '#ea580c',
53
- '#4f46e5',
54
- '#65a30d',
55
- '#be123c',
56
- ];
80
+ export const PALETTE = PALETTE_ENTRIES.map((entry) => entry.reference);
57
81
  function matches(rule, error) {
58
82
  return rule.targets.some((target) => {
59
83
  const text = error[target];
@@ -80,26 +104,43 @@ export function createCategorizer(options) {
80
104
  if (color)
81
105
  colors.set(name, color);
82
106
  }
83
- const taken = new Set([...colors.values(), UNCATEGORIZED_COLOR].map((color) => color.toLowerCase()));
84
- let next = 0;
85
- for (const name of names) {
86
- if (colors.has(name))
87
- continue;
88
- const free = PALETTE.slice(next).concat(PALETTE.slice(0, next)).find((color) => !taken.has(color));
89
- const color = free ?? PALETTE[next % PALETTE.length];
90
- next = (PALETTE.indexOf(color) + 1) % PALETTE.length;
91
- taken.add(color);
92
- colors.set(name, color);
93
- }
94
107
  const builtInNames = new Set(builtIns.map((rule) => rule.name));
108
+ /**
109
+ * Colors of the categories in `used` (names in rule order). Explicit colors
110
+ * (configured or built-in) never change. Each category without one takes,
111
+ * in config rule order, the first palette color that no category of the
112
+ * report uses, in palette order (gold last). Once every color is taken,
113
+ * colors repeat in palette order. The result only depends on the config and
114
+ * on which categories the report contains.
115
+ */
116
+ function assignColors(used) {
117
+ const assigned = new Map();
118
+ const taken = new Set([UNCATEGORIZED_COLOR]);
119
+ for (const name of used) {
120
+ const color = colors.get(name);
121
+ if (color) {
122
+ assigned.set(name, color);
123
+ taken.add(color.toLowerCase());
124
+ }
125
+ }
126
+ let repeated = 0;
127
+ for (const name of used) {
128
+ if (assigned.has(name))
129
+ continue;
130
+ const color = PALETTE.find((candidate) => !taken.has(candidate)) ?? PALETTE[repeated++ % PALETTE.length];
131
+ taken.add(color);
132
+ assigned.set(name, color);
133
+ }
134
+ return assigned;
135
+ }
95
136
  return {
96
137
  categorize(error) {
97
138
  return rules.find((rule) => matches(rule, error))?.name ?? options.uncategorized;
98
139
  },
99
140
  definitions(used) {
100
- const definitions = names
101
- .filter((name) => used.has(name))
102
- .map((name) => ({ name, color: colors.get(name), builtIn: builtInNames.has(name) }));
141
+ const usedNames = names.filter((name) => used.has(name));
142
+ const assigned = assignColors(usedNames);
143
+ const definitions = usedNames.map((name) => ({ name, color: assigned.get(name), builtIn: builtInNames.has(name) }));
103
144
  if (used.has(options.uncategorized)) {
104
145
  definitions.push({ name: options.uncategorized, color: UNCATEGORIZED_COLOR, builtIn: true });
105
146
  }
@@ -8,8 +8,13 @@ import type { RunScope } from '../schema/v1.js';
8
8
  * command line (`config.argv` from 1.61, `process.argv` before).
9
9
  * - `--project` is visible as `config.filteredProjects` from 1.64; earlier, the
10
10
  * command line plus the projects present in the suite are used.
11
+ * - `--no-deps` leaves `project.dependencies` / `teardown` untouched in the
12
+ * config reporters get: only the command line tells that they did not run.
11
13
  * - A `grep` / `grepInvert` in the config file is visible in the config.
12
14
  * - `test.only` is only visible through Playwright's internal `_only` flag.
15
+ * - `default: false` on a project (1.64+) is only visible through Playwright's
16
+ * internal config; a plain run already leaves those projects out of
17
+ * `config.filteredProjects`.
13
18
  *
14
19
  * Limits are documented in docs/schema/v1.md ("Run scope").
15
20
  */
@@ -38,6 +43,11 @@ export interface ScopeInput {
38
43
  projects: readonly ProjectRef[];
39
44
  /** `config.filteredProjects` names when Playwright exposes them (1.64+); empty under merge-reports. */
40
45
  filteredProjects: readonly string[] | undefined;
46
+ /**
47
+ * Names of the projects with `default: false` (Playwright 1.64+), read from
48
+ * Playwright's internal config; undefined when it is not available.
49
+ */
50
+ nonDefaultProjects: readonly string[] | undefined;
41
51
  /** Names of the projects that have a suite in this run. */
42
52
  runProjects: readonly string[];
43
53
  /** A `test.only` / `describe.only` is in effect. */
@@ -7,8 +7,13 @@
7
7
  * command line (`config.argv` from 1.61, `process.argv` before).
8
8
  * - `--project` is visible as `config.filteredProjects` from 1.64; earlier, the
9
9
  * command line plus the projects present in the suite are used.
10
+ * - `--no-deps` leaves `project.dependencies` / `teardown` untouched in the
11
+ * config reporters get: only the command line tells that they did not run.
10
12
  * - A `grep` / `grepInvert` in the config file is visible in the config.
11
13
  * - `test.only` is only visible through Playwright's internal `_only` flag.
14
+ * - `default: false` on a project (1.64+) is only visible through Playwright's
15
+ * internal config; a plain run already leaves those projects out of
16
+ * `config.filteredProjects`.
12
17
  *
13
18
  * Limits are documented in docs/schema/v1.md ("Run scope").
14
19
  */
@@ -49,8 +54,17 @@ const VALUE_OPTIONS = new Set([
49
54
  '--ui-host',
50
55
  '--ui-port',
51
56
  ]);
57
+ /**
58
+ * Variadic options (`--project <name...>`): like commander, they take every
59
+ * following token up to the next one starting with '-'. `--project=a` takes one.
60
+ */
61
+ const VARIADIC_OPTIONS = new Set(['--project']);
52
62
  /** Options with an optional value: like commander, they take the next token unless it starts with '-'. */
53
63
  const OPTIONAL_VALUE_OPTIONS = new Set(['--only-changed', '-u', '--update-snapshots']);
64
+ /** What commander takes for an option rather than a value: '-' followed by anything. */
65
+ function isOptionLike(arg) {
66
+ return arg.length > 1 && arg.startsWith('-');
67
+ }
54
68
  /** Parses `playwright test` arguments from a full argv; null when the command is not `test`. */
55
69
  export function parseTestCommand(argv) {
56
70
  const args = argv.slice(2);
@@ -78,9 +92,13 @@ export function parseTestCommand(argv) {
78
92
  command.options.add(name);
79
93
  if (name !== arg)
80
94
  continue;
81
- if (VALUE_OPTIONS.has(name))
95
+ if (VARIADIC_OPTIONS.has(name)) {
96
+ while (args[i + 1] !== undefined && !isOptionLike(args[i + 1]))
97
+ i++;
98
+ }
99
+ else if (VALUE_OPTIONS.has(name))
82
100
  i++;
83
- else if (OPTIONAL_VALUE_OPTIONS.has(name) && args[i + 1] !== undefined && !args[i + 1]?.startsWith('-'))
101
+ else if (OPTIONAL_VALUE_OPTIONS.has(name) && args[i + 1] !== undefined && !isOptionLike(args[i + 1]))
84
102
  i++;
85
103
  }
86
104
  return command;
@@ -104,12 +122,24 @@ function withDependencies(selected, projects) {
104
122
  }
105
123
  return result;
106
124
  }
125
+ /**
126
+ * The reference for a full run is the set of default projects: a project with
127
+ * `default: false` only runs when selected with --project, and its absence
128
+ * never makes a run partial.
129
+ */
107
130
  function hasProjectFilter(input, command) {
108
131
  const all = input.projects.map((project) => project.name);
109
132
  // merge-reports exposes an empty list: a real run always selects at least one project.
110
133
  if (input.filteredProjects && input.filteredProjects.length > 0) {
111
- const running = withDependencies(input.filteredProjects, input.projects);
112
- return all.some((name) => !running.has(name));
134
+ // Without the internal flags, a plain `playwright test` already ran every default project.
135
+ if (!input.nonDefaultProjects && command && !command.options.has('--project'))
136
+ return false;
137
+ // With --no-deps, dependencies and teardowns of the selected projects do not run.
138
+ const running = command?.options.has('--no-deps')
139
+ ? new Set(input.filteredProjects)
140
+ : withDependencies(input.filteredProjects, input.projects);
141
+ const nonDefault = new Set(input.nonDefaultProjects ?? []);
142
+ return all.some((name) => !nonDefault.has(name) && !running.has(name));
113
143
  }
114
144
  if (!command?.options.has('--project'))
115
145
  return false;
@@ -7,8 +7,9 @@ export interface NormalizeOptions {
7
7
  }
8
8
  /**
9
9
  * Normalizes an error message so the same failure produces the same text
10
- * across tests and runs: ANSI codes and the call log are removed, volatile
11
- * values are replaced with placeholders and whitespace is collapsed.
10
+ * across tests, retries and runs: ANSI codes and the call log are removed,
11
+ * only the stable part of Playwright messages is kept, volatile values are
12
+ * replaced with placeholders and whitespace is collapsed.
12
13
  */
13
14
  export declare function normalizeErrorMessage(message: string, options?: NormalizeOptions): string;
14
15
  /** First 12 hex chars of sha256 of the normalized message. Independent of the test. */
@@ -6,6 +6,34 @@ export function stripAnsi(text) {
6
6
  }
7
7
  /** Playwright appends the action log after a line starting with "Call log:". */
8
8
  const CALL_LOG = /^[ \t]*Call log:/m;
9
+ /**
10
+ * A strict mode violation lists the matching elements, one per line:
11
+ * ` 1) <li>Order 13</li> aka locator('li').first()`. Their positions and
12
+ * content change when the page gains an item, so the list is dropped and the
13
+ * count becomes `<n>`. Playwright ends a long list with a `...` line.
14
+ */
15
+ const STRICT_MODE = /\bstrict mode violation\b/;
16
+ const STRICT_MODE_COUNT = /(\bstrict mode violation:.*?\bresolved to )\d+( elements?\b)/g;
17
+ const ENUMERATED_ELEMENT = /^[ \t]*(?:\d+\) <.*|\.\.\.)[ \t]*$/;
18
+ /**
19
+ * Lines of a locator assertion that identify the failure: the `Error:` lines
20
+ * (the matcher line, and `Error: element(s) not found` or a strict mode
21
+ * violation under it), a matcher line without `Error:` (after a custom
22
+ * message), `Locator:`, and `Expected…:` / `Received…:`. The rest (timeout,
23
+ * screenshot pixel counts, diff bodies, element lists) varies between runs.
24
+ */
25
+ const LOCATOR_LINE = /^[ \t]*Locator:/m;
26
+ const STABLE_LINE = /^[ \t]*(?:Error:|Locator:|(?:Expected|Received)\b[^:\n]*:|expect(?:\.\w+)*\()/;
27
+ /** The stable part of a Playwright message (the call log is already cut). See "Error signature" in docs/schema/v1.md. */
28
+ function stablePart(text) {
29
+ let lines = text.split('\n');
30
+ if (STRICT_MODE.test(text)) {
31
+ lines = lines.filter((line) => !ENUMERATED_ELEMENT.test(line)).map((line) => line.replace(STRICT_MODE_COUNT, '$1<n>$2'));
32
+ }
33
+ if (LOCATOR_LINE.test(text))
34
+ lines = lines.filter((line) => STABLE_LINE.test(line));
35
+ return lines.join('\n');
36
+ }
9
37
  /**
10
38
  * `merge-reports` prefixes the run errors of a blob whose config has a `tag`
11
39
  * with `(<tags>) `, tags joined by spaces. Config tags always start with `@`.
@@ -16,13 +44,17 @@ export function stripMergeTagPrefix(text) {
16
44
  return text.replace(MERGE_TAG_PREFIX, '');
17
45
  }
18
46
  /**
19
- * Ordered replacements. The order matters: URLs before paths (a URL contains
20
- * slashes), paths before hex and numbers (so a path is replaced as a whole),
21
- * hex before numbers. See "Error signature" in docs/schema/v1.md.
47
+ * Ordered replacements. The order matters: URLs before e-mails (a URL may
48
+ * contain `user@host`) and paths (a URL contains slashes), e-mails and paths
49
+ * before identifiers, identifiers before durations, hex and numbers (so each
50
+ * is replaced as a whole), hex before numbers. See "Error signature" in
51
+ * docs/schema/v1.md.
22
52
  */
23
53
  const REPLACEMENTS = [
24
54
  [/\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi, '<uuid>'],
25
55
  [/\b[a-z][a-z0-9+.-]*:\/\/[^\s'"`<>()[\]{}]*[^\s'"`<>()[\]{}.,;:!?]/gi, '<url>'],
56
+ // Local part, then a domain with at least one dot and an alphabetic TLD.
57
+ [/(?<![\w.%+-])[\w.%+-]+@[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*\.[a-z]{2,}(?![\w-])/gi, '<email>'],
26
58
  // ISO 8601 date with optional time and zone.
27
59
  [/\b\d{4}-\d{2}-\d{2}(?:[T ]\d{2}:\d{2}(?::\d{2}(?:[.,]\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?)?(?![\w:])/g, '<date>'],
28
60
  // Time of day.
@@ -32,6 +64,13 @@ const REPLACEMENTS = [
32
64
  // Absolute POSIX paths with at least two segments, and Windows drive paths.
33
65
  [/(?<![\w.~:/\\-])\/(?:[\w.@+~-]+\/)+[\w.@+~-]+\/?/g, '<path>'],
34
66
  [/\b[A-Za-z]:[\\/](?:[^\\/\s'"`:*?<>|]+[\\/])*[^\\/\s'"`:*?<>|]+/g, '<path>'],
67
+ // nanoid-like: 20+ chars of [A-Za-z0-9_-] with a lowercase letter, an
68
+ // uppercase letter and a digit. Mixed case keeps kebab-case and snake_case
69
+ // names out; a digit keeps camelCase words out.
70
+ [/(?<![\w-])(?=[\w-]*[a-z])(?=[\w-]*[A-Z])(?=[\w-]*\d)[\w-]{20,}(?![\w-])/g, '<id>'],
71
+ // cuid-like: 20+ lowercase letters and digits, with a digit and a letter
72
+ // beyond a-f (pure hex strings stay <hex>).
73
+ [/(?<![0-9A-Za-z])(?=[a-z0-9]*\d)(?=[a-z0-9]*[g-z])[a-z0-9]{20,}(?![0-9A-Za-z])/g, '<id>'],
35
74
  [/\b\d+(?:\.\d+)?\s?(?:ms|milliseconds?|s|secs?|seconds?|m|mins?|minutes?|h|hours?)\b/gi, '<duration>'],
36
75
  // 8+ hex chars with at least one letter (pure digit runs fall through to <n>).
37
76
  [/(?<![0-9A-Za-z])(?=[0-9a-f]*[a-f])[0-9a-f]{8,}(?![0-9A-Za-z])/gi, '<hex>'],
@@ -39,8 +78,9 @@ const REPLACEMENTS = [
39
78
  ];
40
79
  /**
41
80
  * Normalizes an error message so the same failure produces the same text
42
- * across tests and runs: ANSI codes and the call log are removed, volatile
43
- * values are replaced with placeholders and whitespace is collapsed.
81
+ * across tests, retries and runs: ANSI codes and the call log are removed,
82
+ * only the stable part of Playwright messages is kept, volatile values are
83
+ * replaced with placeholders and whitespace is collapsed.
44
84
  */
45
85
  export function normalizeErrorMessage(message, options = {}) {
46
86
  let text = stripAnsi(message);
@@ -49,6 +89,7 @@ export function normalizeErrorMessage(message, options = {}) {
49
89
  const callLog = CALL_LOG.exec(text);
50
90
  if (callLog)
51
91
  text = text.slice(0, callLog.index);
92
+ text = stablePart(text);
52
93
  for (const [pattern, placeholder] of REPLACEMENTS) {
53
94
  text = text.replace(pattern, placeholder);
54
95
  }