@qartisan/playwright-loupe 0.1.1 → 0.1.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,6 +4,42 @@ 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
+ ## [0.1.3] - 2026-10-21
8
+
9
+ ### Added
10
+
11
+ - **Common failure points.** A new Overview card, after the run errors and before the error categories, lists the functions of your code that appear in the stack of several failures, in different spec files and error groups: when one broken fixture or helper fails many tests with different messages, it shows the function they share. A lead, not a diagnosis. Each point gives its function, file and the lines seen, the functions folded under it ("via …"), its tests, files and error groups, the failed / flaky split and the categories involved, and opens the matching tests. Thresholds: 5 tests (a test in several projects counts once), 3 spec files, 2 error groups; 5 points at most. Spec files, anonymous functions, `node_modules`, Node and Playwright internals are left out; helpers outside the root dir are kept, with `../` paths. See [Common failure points](README.md#common-failure-points).
12
+ - **Failure point filter** in the Tests view and the sidebar, after the error category (`point` in the URL, `#` encoded as `%23`).
13
+ - **Error groups only seen after a test timeout are folded.** When the test timeout stops a test, the pending action fails too, as a second error of the attempt (`Request context disposed`, `Target page, context or browser has been closed`…). A group whose every error is such a secondary error (after `Test timeout of <n>ms exceeded.`, or the fixture and hook variants of the test timeout) now sits in a collapsed disclosure at the end of the Errors view's list and of each category, and group counts leave it out with a mention (`22 error groups · 18 more only seen after a test timeout`). Test counts and test categories are unchanged; `n` / `p` walk these groups last; their pages stay reachable, and the test detail labels the error "Raised after the test timeout". See [Groups only seen after a test timeout](README.md#groups-only-seen-after-a-test-timeout).
14
+ - **Why a test was skipped.** The reason given in the test code (`test.skip(condition, 'reason')`, `test.fixme(…)`, `testInfo.skip(…)`, in a test, a `describe` or a hook) now shows as `Skipped: <reason>` or `Fixme: <reason>` in Suite health, on a muted line under the title in the Tests view (one line, the full text in the tooltip and the accessible name), and next to the outcome in the test detail header. Without a reason: `Skipped (no reason given)` / `Fixme (no reason given)`. A test Playwright skipped without any skip annotation (a failed dependency project or earlier test of a serial group, `--max-failures`, a stopped run) shows `Not run`, with a sentence listing these usual causes, never asserting one. The search (`q`) also matches the reason's text. Read from the existing annotations: no change to the JSON. See [Skip reasons](README.md#skip-reasons).
15
+ - `npm run perf:sample` writes a third dataset, `cascade`, with realistic stacks through invented helpers (some outside the test dir) and 50 spec files, and also times the opening of the Overview.
16
+
17
+ ### Changed
18
+
19
+ - **Error signatures of `toBeOK()` keep the response status.** For `expect(response).toBeOK()` (and `not.toBeOK()`), the status is only in Playwright's call log, which signatures cut: a 429, a 404 and a 503 shared one error group, shown under the category of its first error. The signature now keeps the final response line of the call log (`← 503 Service Unavailable`, the last one after redirects), so each status gets its own group, matching its category. The same status on different endpoints still shares one group. Other messages keep their signature; `toBeOK()` failures get a new one, so their groups do not match those of earlier reports. No change to the JSON shape (`schemaVersion` stays 1). See [Error signature](docs/schema/v1.md#error-signature).
20
+ - Suite health's skipped tests use the new wording (`Skipped: <reason>`, `Fixme (no reason given)`, `Not run`) instead of a `fixme` badge and "No reason given"; a test with several skip annotations shows the first one with a reason.
21
+
22
+ ## [0.1.2] - 2026-10-21
23
+
24
+ ### Security
25
+
26
+ - **Secrets are redacted from the report.** Reports are shared (CI artifacts, links), but they could contain secrets in clear: the value typed by `fill` into a password field is part of Playwright's step title (`Fill "<value>"`), and the call log of a failing API call prints the request and response headers, session cookies and bearer tokens included. Loupe now replaces them with `[redacted]` in `report.json`, and therefore in `index.html`, before writing them and before computing error signatures and categories. No change to your tests.
27
+ - **Input steps** (on by default): the value of `fill`, `type`, `pressSequentially`, `keyboard.type` and `keyboard.insertText` steps whose target has a secret-looking name, read from the step subtitle (Playwright 1.63+) or, on every version, from the source line of the call. That fallback also covers `keyboard.*` and neutral locators when the code names the value (`fill(password)`). The same value is redacted where Playwright repeats it in that attempt's errors (`fill("[redacted]")` in call logs, assertion lines).
28
+ - **HTTP headers** (on by default): `cookie`, `set-cookie`, `authorization`, `proxy-authorization` and any header with a secret-looking name, in call logs (`- cookie: [redacted]`).
29
+ - **URL parameters** (on by default): query and fragment parameters with a secret-looking name, wherever URLs appear (navigation steps, call logs, error messages, stdout), keeping the name: `?token=[redacted]`, `#access_token=[redacted]`.
30
+ - **Environment values** (opt-in): the values of the variables listed in the new `redact.env` option, or added through `LOUPE_REDACT_ENV`, everywhere in the report. Values shorter than 6 characters and unset variables are skipped with a warning naming the variable.
31
+ - New `redact` option (`patterns`, `mode`, `env`), validated at startup; one list of secret-looking names for input steps, headers and URL parameters, with whole-word matching (`auth` matches `x-auth-token`, not `Author`). The environment can add names, never remove them.
32
+ - **Not redacted**: attachments (traces, screenshots and videos contain typed values: treat a report with traces as private), snippets (source code from your repository), attachment file names. See [Secret redaction](docs/config.md#secret-redaction). Reports written by earlier versions are not rewritten.
33
+
34
+ ### Changed
35
+
36
+ - **Error signatures keep HTTP status codes.** A status code (100–599) in an HTTP context is no longer replaced with `<n>`, so `Unable to delete the member with API with error 401` and `… with error 500` are two error groups instead of one. Other numbers are normalized as before. This changes how signatures are computed, not the JSON shape (`schemaVersion` stays 1): signatures of messages with an HTTP status change, so groups do not match across this upgrade for those errors. See [Error signature](docs/schema/v1.md#error-signature).
37
+ - **Wider HTTP context, shared by categories and signatures.** A status code now also counts as an HTTP status after `error` (`with error 503`, `Error: 503`) and before its standard reason phrase (`401 Unauthorized`, previously only for 5xx). Categories change accordingly: an error such as `… with error 503` is now **Server error** and `… error 429` **Rate limited**, where they could be Uncategorized (or Assertion, Timeout) before. See [HTTP context](docs/schema/v1.md#http-context).
38
+
39
+ ### Fixed
40
+
41
+ - Docs: `Step.subtitle` is provided from Playwright **1.63**, not 1.61, and before that the target of an action is not always in the title: Playwright 1.53 to 1.62 leave it out (`Click`, `Fill "<value>"`). Only 1.52 writes it there (`locator.getByRole('button').click`). The report itself is unchanged.
42
+
7
43
  ## [0.1.1] - 2026-10-18
8
44
 
9
45
  ### Added
package/README.md CHANGED
@@ -38,8 +38,8 @@ A run that broke outside its tests (a spec file that fails to import, a failing
38
38
  The report is a single self-contained HTML file. It works offline, straight from `file://`, in light and dark themes.
39
39
 
40
40
  - **Header:** the run's verdict and status counts, its start, duration and environment; the shortcuts and theme controls stay top-right at every width.
41
- - **Overview:** verdict with pass rate, run errors first when the run broke outside its tests, error categories, project cards, and the run details in three groups: Run (scope, CI, workers, shard), Code (commit, short with a copy button; branch; repository) and Environment (Playwright, Loupe, Node, OS). Empty values are left out.
42
- - **Errors:** every error group, built from normalized error signatures, with the failed / flaky split and links to the affected tests. A group page has previous / next buttons: from a category, they stay within that category.
41
+ - **Overview:** verdict with pass rate, run errors first when the run broke outside its tests, [common failure points](#common-failure-points), error categories, project cards, and the run details in three groups: Run (scope, CI, workers, shard), Code (commit, short with a copy button; branch; repository) and Environment (Playwright, Loupe, Node, OS). Empty values are left out.
42
+ - **Errors:** every error group, built from normalized error signatures, with the failed / flaky split and links to the affected tests. Groups [only seen after a test timeout](#groups-only-seen-after-a-test-timeout) are folded at the end. A group page has previous / next buttons: from a category, they stay within that category.
43
43
  - **Test detail:** title path, outcome, project, `file:line`, tags, annotations and dimensions, then one tab per attempt (the last failing attempt first; `?attempt=` in the URL): errors with Expected / Received and diffs highlighted and the code snippet, steps (`test.step` and failing steps, the failing path expanded; "Show all steps" for hooks, fixtures and API calls), attachments, stdout and stderr. Previous / next walk the Tests view's current list.
44
44
  - **Attachments**, per attempt, linked from `data/` next to the report (never inlined in the HTML), all working from `file://`:
45
45
  - **Screenshot comparisons**: a failed `toHaveScreenshot` / `toMatchSnapshot` gives one card per image name, before the other screenshots, with four modes: Diff, Side by side (expected | actual, at one scale), Slider (actual over expected, a divider to drag or move with the arrow keys) and Overlay (actual over expected, its opacity to set). Only the modes the images allow are offered; Diff is the default when there is a diff image. The chosen mode applies to every comparison until the report is closed. Each image's size is shown, with a warning when expected and actual differ: slider and overlay align them top-left. "Open in viewer" shows the same modes in the viewer below.
@@ -50,10 +50,40 @@ The report is a single self-contained HTML file. It works offline, straight from
50
50
  - **Copy buttons:** rerun commands (this test, the failed tests of its file, the failed tests of the current list) and an AI prompt describing the failure, in English. Both are only copied to the clipboard; nothing is sent anywhere. Projects failing on the same lines share one command (`--project=a --project=b`); otherwise each project gets its own, joined with `&&`.
51
51
  - The commands target **POSIX shells** (bash, zsh; not `cmd.exe`) and run from the Playwright root directory: paths in the report are relative to it.
52
52
  - A `file:line` argument targets the line where the test is declared and reruns every test declared on that line. A command is only valid while the file has not changed since the run.
53
- - **Suite health:** dimensions from tags and annotations (with hints for unknown or inconsistent values), slowest, skipped and flaky tests.
54
- - **Tests:** every test in one sortable list (by default in declaration order, as in the sidebar: project, file, line; the Status header sorts failed, flaky, passed, skipped), filtered by status, error category, project, tag and dimension, with the count each filter would give. Header counts, error categories and dimension values open it pre-filtered. Large lists show their first 500 rows, with "Show 500 more" and "Show all". Under each title, chips show what the test covers: its dimension values first (dimensions in config order, the value alone, `api`, with the dimension in the tooltip), then its other tags (`@smoke`), sorted. A tag a dimension takes appears only as that dimension's value. A chip adds its value to the filters, keeping the others and the sort; an active chip (with a ×) removes it, and Back undoes either. Up to four chips per row, active chips always among them; "+N" shows the rest.
55
- - **Sidebar:** search (`/` to focus; every word must match, ignoring case and accents, the title path, file, project, tags or dimension values: `smoke`, `api`, `layer:api`), projects, folders, files, `describe` blocks and tests, with status icons, failure counts and keyboard navigation. The folder shared by every file is not repeated.
56
- - **Filters in the URL:** search, filters and sort live in the link, so a filtered view can be shared or bookmarked, even from `file://`. A project without a `name` (Playwright's default project) cannot be selected in the project filter: an empty name cannot be written in the URL. Give your projects a name to filter by them. Each tag is filterable in one place only: a tag a dimension is built from (`@layer:ui` with a `layer` dimension, configured or automatic) is in that dimension's filter (Layer: `ui`), not in the tag filter, so the two can never be combined into an empty result. Unprefixed tags (`@smoke`) and prefixed tags no dimension uses stay in the tag filter.
53
+ - **Suite health:** dimensions from tags and annotations (with hints for unknown or inconsistent values), slowest, skipped (with why, see [Skip reasons](#skip-reasons)) and flaky tests.
54
+ - **Tests:** every test in one sortable list (by default in declaration order, as in the sidebar: project, file, line; the Status header sorts failed, flaky, passed, skipped), filtered by status, error category, failure point, project, tag and dimension, with the count each filter would give. Header counts, error categories, failure points and dimension values open it pre-filtered. Large lists show their first 500 rows, with "Show 500 more" and "Show all". Under each title, chips show what the test covers: its dimension values first (dimensions in config order, the value alone, `api`, with the dimension in the tooltip), then its other tags (`@smoke`), sorted. A tag a dimension takes appears only as that dimension's value. A chip adds its value to the filters, keeping the others and the sort; an active chip (with a ×) removes it, and Back undoes either. Up to four chips per row, active chips always among them; "+N" shows the rest. A skipped test shows why on a muted line under its title, above the chips.
55
+ - **Sidebar:** search (`/` to focus; every word must match, ignoring case and accents, the title path, file, project, tags, dimension values or the reason a test was skipped: `smoke`, `api`, `layer:api`, `sandbox`), projects, folders, files, `describe` blocks and tests, with status icons, failure counts and keyboard navigation. The folder shared by every file is not repeated.
56
+ - **Filters in the URL:** search, filters and sort live in the link, so a filtered view can be shared or bookmarked, even from `file://`. A project without a `name` (Playwright's default project) cannot be selected in the project filter: an empty name cannot be written in the URL. Give your projects a name to filter by them. Each tag is filterable in one place only: a tag a dimension is built from (`@layer:ui` with a `layer` dimension, configured or automatic) is in that dimension's filter (Layer: `ui`), not in the tag filter, so the two can never be combined into an empty result. Unprefixed tags (`@smoke`) and prefixed tags no dimension uses stay in the tag filter. The failure point filter (`point`, the value `<file>#<function>` encoded like any other, `#` as `%23`: `point=fixtures%2Fsession.ts%23signIn`) offers the points of the Overview card; a test matches when the function is in the stack of one of its failing attempts.
57
+
58
+ ### Skip reasons
59
+
60
+ A skipped test says why, in Suite health, under its title in the Tests view and next to its outcome in the test detail. The reason is the one given in your test code, read from the `skip` / `fixme` annotation Playwright records for `test.skip(condition, 'reason')`, `test.fixme(condition, 'reason')`, `testInfo.skip(…)`, in a test, a `describe` or a hook:
61
+
62
+ - `Skipped: <reason>` / `Fixme: <reason>`;
63
+ - `Skipped (no reason given)` / `Fixme (no reason given)` when the call has no reason (`test.skip('title', body)`, `test.describe.fixme`…);
64
+ - `Not run` when Playwright skipped the test without any skip annotation: usually because a dependency project or an earlier test of its serial group failed, the failure limit (`--max-failures`) was reached, or the run was stopped. The report does not say which, so Loupe does not guess.
65
+
66
+ The search matches the reason's text (not the `Skipped` or `Not run` labels). Reasons go through the same secret redaction as every other string of the report.
67
+
68
+ ### Common failure points
69
+
70
+ When one problem breaks many tests, their errors rarely share a message: the same function fails at different lines, with different errors, in different categories. The Overview card **Common failure points** lists the functions of your code that appear in the stack of several failures, in different spec files and error groups. **It is a lead, not a diagnosis:** a shared helper also shows up when unrelated failures go through it.
71
+
72
+ - **What is counted:** every error of every failing attempt, flaky tests included (the card gives the failed / flaky split), read from its stack trace. A point is a function in a file, whatever the line: `fixtures/session.ts#signIn`, with the lines seen. Paths are relative to the Playwright root dir, with `../` for helpers outside it (`../helpers/api.ts`). Spec files (any file holding a test of the report), anonymous functions, `node_modules`, Node internals and Playwright's own frames are left out.
73
+ - **Thresholds:** a point is shown when at least **5** distinct tests (a test run in several projects counts once), **3** spec files and **2** error groups go through it. At most **5** points, the most tests first. A point below a threshold is shown nowhere.
74
+ - **Folding:** a function only reached through another one, by some of its tests, is listed under it (`via checkResponse`) rather than on its own. When their tests only partly overlap, both are shown.
75
+ - Each point opens its tests in the Tests view (the Failure point filter). The root dir is found from the stacks (the frames of each test's own file); when no stack shows it, the card is hidden.
76
+
77
+ Nothing is configured and nothing changes in your tests: the points are derived when the report opens, from the stacks Playwright records. The thresholds may change while the heuristic is tuned.
78
+
79
+ ### Groups only seen after a test timeout
80
+
81
+ When a test times out, Playwright stops it and the action still running fails too (`Request context disposed`, `Target page, context or browser has been closed`, `locator.click: Test timeout of 30000ms exceeded`): a second error of the same attempt. Such follow-on errors can make up many error groups of a slow run.
82
+
83
+ - An error is **secondary** when it is not the first error of its attempt and that first error is Playwright's test timeout: `Test timeout of <n>ms exceeded.`, also while setting up or tearing down a fixture, in a `beforeEach` / `afterEach` hook, a fixture's own timeout and a `beforeAll` / `afterAll` hook timeout.
84
+ - A group is **folded** when every one of its errors is secondary. A group seen at least once as a first error stays visible.
85
+ - In the Errors view, folded groups move to a collapsed "N groups only seen after a test timeout" at the end of the list and of each category. Group counts leave them out and say so (`22 error groups · 18 more only seen after a test timeout`), on the Overview too; test counts are unchanged. `n` / `p` walk the visible groups first, then the folded ones.
86
+ - A folded group's page stays reachable, and in the test detail each secondary error keeps its place, labelled "Raised after the test timeout". The category of a test does not change: the second error is sometimes the most telling one.
57
87
 
58
88
  ### Keyboard shortcuts
59
89
 
@@ -103,6 +133,21 @@ Options can also be passed inline (`['@qartisan/playwright-loupe', { title: 'Nig
103
133
 
104
134
  Every option, the lookup rules and the environment variables are documented in [`docs/config.md`](docs/config.md).
105
135
 
136
+ ### Security
137
+
138
+ Reports are meant to be shared, so Loupe removes secrets from `report.json` and `index.html` before writing them, replacing each with `[redacted]`:
139
+
140
+ - **typed values** of `fill`, `type`, `pressSequentially` and `keyboard.*` steps on secret-looking fields (a password field, `getByLabel('Password')`, or a source line that names the value, `fill(password)`), and wherever Playwright repeats them in that attempt's errors;
141
+ - **HTTP header values** printed in call logs (`cookie`, `set-cookie`, `authorization`, and any header with a secret-looking name);
142
+ - **URL parameters** with a secret-looking name, wherever URLs appear (navigation steps, call logs, error messages): `?token=[redacted]`, `&access_token=[redacted]`;
143
+ - the **values of the environment variables you list** in `redact.env` (names only, never values), everywhere in the report. It is also the fallback for what the names above miss: a secret in a URL parameter with a neutral name (`?sig=…`), or printed by your code.
144
+
145
+ ```ts
146
+ redact: { env: ['E2E_PASSWORD', 'API_TOKEN'] },
147
+ ```
148
+
149
+ **Attachments are not altered**: traces, screenshots and videos contain typed values and visible secrets, so a report with traces must be treated as private. Snippets (source excerpts around errors) are left as they are: they come from your repository. See [Secret redaction](docs/config.md#secret-redaction).
150
+
106
151
  ## Compatibility
107
152
 
108
153
  | | Supported | Tested in CI |
@@ -3,7 +3,7 @@ type Env = Readonly<Record<string, string | undefined>>;
3
3
  export declare const DEFAULT_OUTPUT_FOLDER = "loupe-report";
4
4
  export declare const DEFAULT_TITLE = "Loupe report";
5
5
  export declare const DEFAULT_UNCATEGORIZED = "Uncategorized";
6
- /** Environment variables mapped onto config options. `LOUPE_CONFIG` and `LOUPE_RUN_SCOPE` are handled apart. */
6
+ /** Environment variables mapped onto config options. `LOUPE_CONFIG`, `LOUPE_RUN_SCOPE` and `LOUPE_REDACT_ENV` are handled apart. */
7
7
  export declare const ENV_OPTIONS: {
8
8
  readonly LOUPE_OUTPUT_FOLDER: 'outputFolder';
9
9
  readonly LOUPE_TITLE: 'title';
@@ -22,6 +22,17 @@ export declare class LoupeConfigError extends Error {
22
22
  export declare function stripPlaywrightKeys(options: Record<string, unknown>): Record<string, unknown>;
23
23
  /** The environment layer: options set through `LOUPE_*` variables. Empty variables are ignored. */
24
24
  export declare function envLayer(env: Env): LoupeConfig;
25
+ /** Comma-separated variable names added to `redact.env`. */
26
+ export declare const REDACT_ENV = "LOUPE_REDACT_ENV";
27
+ /**
28
+ * Names from `LOUPE_REDACT_ENV`, with a problem for each invalid one (its text
29
+ * is not echoed: it may be a value pasted by mistake). The environment can only
30
+ * add names to `redact.env`, never remove or disable redaction.
31
+ */
32
+ export declare function redactEnvNames(env: Env): {
33
+ names: string[];
34
+ problems: string[];
35
+ };
25
36
  /**
26
37
  * Merges `layer` over `base`: nested objects (`categories`, `dimensions`,
27
38
  * `screenshots`, `aiPrompt`) merge key by key; arrays and other values replace. An
@@ -1,10 +1,10 @@
1
1
  import path from 'node:path';
2
2
  import { paletteReference } from '../shared/palette.js';
3
- import { validateConfig } from './validate.js';
3
+ import { ENV_NAME, validateConfig } from './validate.js';
4
4
  export const DEFAULT_OUTPUT_FOLDER = 'loupe-report';
5
5
  export const DEFAULT_TITLE = 'Loupe report';
6
6
  export const DEFAULT_UNCATEGORIZED = 'Uncategorized';
7
- /** Environment variables mapped onto config options. `LOUPE_CONFIG` and `LOUPE_RUN_SCOPE` are handled apart. */
7
+ /** Environment variables mapped onto config options. `LOUPE_CONFIG`, `LOUPE_RUN_SCOPE` and `LOUPE_REDACT_ENV` are handled apart. */
8
8
  export const ENV_OPTIONS = {
9
9
  LOUPE_OUTPUT_FOLDER: 'outputFolder',
10
10
  LOUPE_TITLE: 'title',
@@ -42,6 +42,30 @@ export function envLayer(env) {
42
42
  }
43
43
  return layer;
44
44
  }
45
+ /** Comma-separated variable names added to `redact.env`. */
46
+ export const REDACT_ENV = 'LOUPE_REDACT_ENV';
47
+ /**
48
+ * Names from `LOUPE_REDACT_ENV`, with a problem for each invalid one (its text
49
+ * is not echoed: it may be a value pasted by mistake). The environment can only
50
+ * add names to `redact.env`, never remove or disable redaction.
51
+ */
52
+ export function redactEnvNames(env) {
53
+ const raw = envValue(env, REDACT_ENV);
54
+ if (raw === undefined)
55
+ return { names: [], problems: [] };
56
+ const names = [];
57
+ const problems = [];
58
+ raw.split(',').forEach((part, index) => {
59
+ const name = part.trim();
60
+ if (name === '')
61
+ return;
62
+ if (ENV_NAME.test(name))
63
+ names.push(name);
64
+ else
65
+ problems.push(`${REDACT_ENV}: item ${index + 1} must be an environment variable name (letters, digits, _), got a string that is not a variable name (value not shown)`);
66
+ });
67
+ return { names, problems };
68
+ }
45
69
  function isObject(value) {
46
70
  return value !== null && typeof value === 'object' && !Array.isArray(value) && !(value instanceof RegExp);
47
71
  }
@@ -86,9 +110,11 @@ function resolveDimensions(config) {
86
110
  export function resolveConfig(layers) {
87
111
  const fileSource = layers.file ? path.basename(layers.file.path) : '';
88
112
  const env = envLayer(layers.env);
113
+ const redactEnv = redactEnvNames(layers.env);
89
114
  const problems = [
90
115
  ...(layers.file ? validateConfig(layers.file.config, fileSource) : []),
91
116
  ...validateConfig(layers.inline, 'reporter options', { allowConfigFile: true }),
117
+ ...redactEnv.problems,
92
118
  ];
93
119
  if (problems.length > 0)
94
120
  throw new LoupeConfigError(problems);
@@ -115,5 +141,11 @@ export function resolveConfig(layers) {
115
141
  screenshots: { defaultView: merged.screenshots?.defaultView ?? 'side-by-side' },
116
142
  theme: merged.theme ?? 'system',
117
143
  aiPrompt: { includeStack: merged.aiPrompt?.includeStack ?? true },
144
+ redact: {
145
+ mode: merged.redact?.mode ?? 'extend',
146
+ patterns: (merged.redact?.patterns ?? []).map((pattern) => (pattern instanceof RegExp ? stateless(pattern) : pattern)),
147
+ // LOUPE_REDACT_ENV adds to the configured names: a higher layer never removes one.
148
+ env: [...new Set([...(merged.redact?.env ?? []), ...redactEnv.names])],
149
+ },
118
150
  };
119
151
  }
@@ -4,6 +4,8 @@
4
4
  */
5
5
  export declare const CATEGORY_MODES: readonly ['extend', 'replace'];
6
6
  export type CategoryMode = (typeof CATEGORY_MODES)[number];
7
+ export declare const REDACT_MODES: readonly ['extend', 'replace'];
8
+ export type RedactMode = (typeof REDACT_MODES)[number];
7
9
  export declare const CATEGORY_TARGETS: readonly ['message', 'stack', 'step', 'all'];
8
10
  export type CategoryTarget = (typeof CATEGORY_TARGETS)[number];
9
11
  export declare const SCREENSHOT_VIEWS: readonly ['side-by-side', 'slider', 'overlay', 'diff'];
@@ -53,6 +55,18 @@ export interface DimensionsConfig {
53
55
  auto?: boolean;
54
56
  definitions?: DimensionConfig[];
55
57
  }
58
+ export interface RedactConfig {
59
+ /**
60
+ * Secret-looking names, for input step targets and source lines, and for
61
+ * HTTP header names. A string matches whole words (`auth` matches
62
+ * `x-auth-token`, not `Author`); a RegExp is tested as is.
63
+ */
64
+ patterns?: Array<string | RegExp>;
65
+ /** `extend` (default): `patterns` add to the default names. `replace`: only `patterns`; `[]` turns name-based redaction off. */
66
+ mode?: RedactMode;
67
+ /** Names of environment variables whose values are redacted everywhere in the report. Names only, never values. */
68
+ env?: string[];
69
+ }
56
70
  export interface LoupeConfig {
57
71
  /** Folder the report is written to, relative to the Playwright config file. Default: `loupe-report`. */
58
72
  outputFolder?: string;
@@ -72,6 +86,8 @@ export interface LoupeConfig {
72
86
  /** Include each error's stack trace in the copyable AI prompt. Default: `true`. */
73
87
  includeStack?: boolean;
74
88
  };
89
+ /** Secret redaction in report.json and index.html. `LOUPE_REDACT_ENV` adds variable names. */
90
+ redact?: RedactConfig;
75
91
  }
76
92
  /** Inline reporter options: a config, plus the path of the config file. */
77
93
  export interface LoupeReporterOptions extends LoupeConfig {
@@ -121,4 +137,10 @@ export interface ResolvedConfig {
121
137
  aiPrompt: {
122
138
  includeStack: boolean;
123
139
  };
140
+ /** RegExps without the `g` and `y` flags; `env` merged from every layer, without duplicates. */
141
+ redact: {
142
+ mode: RedactMode;
143
+ patterns: Array<string | RegExp>;
144
+ env: string[];
145
+ };
124
146
  }
@@ -3,6 +3,7 @@
3
3
  * reporter options. Every option is optional; see docs/config.md.
4
4
  */
5
5
  export const CATEGORY_MODES = ['extend', 'replace'];
6
+ export const REDACT_MODES = ['extend', 'replace'];
6
7
  export const CATEGORY_TARGETS = ['message', 'stack', 'step', 'all'];
7
8
  export const SCREENSHOT_VIEWS = ['side-by-side', 'slider', 'overlay', 'diff'];
8
9
  export const THEMES = ['system', 'light', 'dark'];
@@ -1,3 +1,5 @@
1
+ /** An environment variable name, as `redact.env` and `LOUPE_REDACT_ENV` take them. */
2
+ export declare const ENV_NAME: RegExp;
1
3
  /** Optimal string alignment distance: Levenshtein plus adjacent transpositions (`titel` → `title` is 1). */
2
4
  export declare function editDistance(a: string, b: string): number;
3
5
  /** The closest known key, when it is close enough to be a typo. */
@@ -1,6 +1,8 @@
1
1
  import { PALETTE_NAMES } from '../shared/palette.js';
2
- import { CATEGORY_MODES, CATEGORY_TARGETS, SCREENSHOT_VIEWS, THEMES } from './types.js';
2
+ import { CATEGORY_MODES, CATEGORY_TARGETS, REDACT_MODES, SCREENSHOT_VIEWS, THEMES } from './types.js';
3
3
  const COLOR = /^#[0-9a-fA-F]{6}$/;
4
+ /** An environment variable name, as `redact.env` and `LOUPE_REDACT_ENV` take them. */
5
+ export const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
4
6
  /** What a tag prefix cannot contain: `@layer:api` has the prefix `layer`. */
5
7
  const INVALID_PREFIX = /[@:\s]/;
6
8
  function describe(value) {
@@ -151,6 +153,37 @@ function values(ctx) {
151
153
  });
152
154
  };
153
155
  }
156
+ /** Secret-looking names: non-empty strings or RegExps. An empty list is valid (with `mode: 'replace'`, it turns them off). */
157
+ function namePatterns(ctx) {
158
+ return (value, path, errors) => {
159
+ if (!Array.isArray(value))
160
+ return fail(ctx, path, 'an array of strings or RegExps', value, errors);
161
+ value.forEach((item, index) => {
162
+ if (item instanceof RegExp)
163
+ return;
164
+ if (typeof item !== 'string' || item.trim() === '')
165
+ fail(ctx, `${path}[${index}]`, 'a non-empty string or a RegExp', item, errors);
166
+ });
167
+ };
168
+ }
169
+ /** Environment variable names. A value with other characters is most likely a secret pasted by mistake: it is not echoed. */
170
+ function envNames(ctx) {
171
+ return (value, path, errors) => {
172
+ if (!Array.isArray(value))
173
+ return fail(ctx, path, 'an array of environment variable names', value, errors);
174
+ const seen = new Set();
175
+ value.forEach((item, index) => {
176
+ if (typeof item !== 'string' || !ENV_NAME.test(item)) {
177
+ const got = typeof item === 'string' ? 'a string that is not a variable name (value not shown)' : describe(item);
178
+ errors.push(`${ctx.source}: ${path}[${index}] must be an environment variable name (letters, digits, _), got ${got}`);
179
+ return;
180
+ }
181
+ if (seen.has(item))
182
+ errors.push(`${ctx.source}: ${path}[${index}] repeats the variable ${item}`);
183
+ seen.add(item);
184
+ });
185
+ };
186
+ }
154
187
  function tagPrefix(ctx) {
155
188
  return (value, path, errors) => {
156
189
  if (typeof value !== 'string' || value === '' || INVALID_PREFIX.test(value)) {
@@ -228,6 +261,7 @@ export function validateConfig(value, source, options = {}) {
228
261
  screenshots: object({ defaultView: oneOf(SCREENSHOT_VIEWS, ctx) }, ctx),
229
262
  theme: oneOf(THEMES, ctx),
230
263
  aiPrompt: object({ includeStack: boolean(ctx) }, ctx),
264
+ redact: object({ patterns: namePatterns(ctx), mode: oneOf(REDACT_MODES, ctx), env: envNames(ctx) }, ctx),
231
265
  };
232
266
  if (options.allowConfigFile)
233
267
  fields['configFile'] = nonEmptyString(ctx);
@@ -2,6 +2,7 @@ import type { FullConfig, FullResult, Suite, TestError, TestStep } from '@playwr
2
2
  import type { LoupeReport, RunScope } from '../schema/v1.js';
3
3
  import { type CategoriesOptions, type ErrorText } from './categories.js';
4
4
  import { type DimensionsOptions } from './dimensions.js';
5
+ import { type RedactOptions } from './redact.js';
5
6
  /** Folder, inside the report folder, that holds the copied attachments. */
6
7
  export declare const DATA_FOLDER = "data";
7
8
  export interface BuildOptions {
@@ -20,12 +21,14 @@ export interface BuildOptions {
20
21
  error: TestError;
21
22
  project: string | null;
22
23
  }>;
24
+ /** Secret redaction; default: the built-in names, no env values. */
25
+ redact?: RedactOptions;
23
26
  }
24
27
  /**
25
28
  * Titles of the failing steps leading to `error`, outermost first: the path to
26
29
  * the deepest step that ended with the same message. Empty when no step did.
27
30
  */
28
- export declare function failingSteps(steps: readonly TestStep[], error: TestError): string[];
31
+ export declare function failingSteps(steps: readonly TestStep[], error: TestError, title?: (step: TestStep) => string): string[];
29
32
  /**
30
33
  * Text an error is categorized on. `steps`: titles of the failing steps, outermost first.
31
34
  * Run errors lose the `merge-reports` tag prefix, so rules match the same text merged or not.
@@ -8,6 +8,7 @@ import { detectEnvironment, detectGit } from './metadata.js';
8
8
  import { collectOutput } from './output.js';
9
9
  import { esmImportLocation, isUsableLocation } from './runErrors.js';
10
10
  import { detectScopeReasons, resolveScope } from './scope.js';
11
+ import { DEFAULT_REDACT, createRedactor, mapReportStrings } from './redact.js';
11
12
  import { errorSignature, stripAnsi, stripMergeTagPrefix } from './signature.js';
12
13
  /** Folder, inside the report folder, that holds the copied attachments. */
13
14
  export const DATA_FOLDER = 'data';
@@ -119,20 +120,20 @@ function isFocused(test) {
119
120
  * Titles of the failing steps leading to `error`, outermost first: the path to
120
121
  * the deepest step that ended with the same message. Empty when no step did.
121
122
  */
122
- export function failingSteps(steps, error) {
123
+ export function failingSteps(steps, error, title = (step) => step.title) {
123
124
  const message = error.message ?? error.value;
124
125
  if (message === undefined)
125
126
  return [];
126
127
  for (const step of [...steps].reverse()) {
127
128
  if (!step.error || (step.error.message ?? step.error.value) !== message)
128
129
  continue;
129
- return [step.title, ...failingSteps(step.steps, error)];
130
+ return [title(step), ...failingSteps(step.steps, error, title)];
130
131
  }
131
132
  // A step may fail with a wrapped message while one of its children has the exact one.
132
133
  for (const step of [...steps].reverse()) {
133
- const inner = failingSteps(step.steps, error);
134
+ const inner = failingSteps(step.steps, error, title);
134
135
  if (inner.length > 0)
135
- return [step.title, ...inner];
136
+ return [title(step), ...inner];
136
137
  }
137
138
  return [];
138
139
  }
@@ -151,6 +152,7 @@ export async function buildReport(config, suite, result, options) {
151
152
  const rootDir = config.rootDir;
152
153
  const store = new AttachmentStore(options.outputFolder);
153
154
  const categorizer = createCategorizer(options.categories ?? DEFAULT_CATEGORIES);
155
+ const redactor = createRedactor(options.redact ?? DEFAULT_REDACT);
154
156
  const dimensionSet = createDimensions(options.dimensions ?? DEFAULT_DIMENSIONS);
155
157
  const categories = new Set();
156
158
  const usedDimensions = new Set();
@@ -161,10 +163,11 @@ export async function buildReport(config, suite, result, options) {
161
163
  /**
162
164
  * `steps`: titles of the failing steps leading to the error, outermost first.
163
165
  * `runError`: the `merge-reports` tag prefix is ignored by categories and the signature, kept in `message` and `stack`.
166
+ * `redact`: applied to the message and the stack before the signature and the category, so they never depend on a secret.
164
167
  */
165
- const toError = (error, steps = [], location = error.location, runError = false) => {
166
- const message = stripAnsi(error.message ?? error.value ?? '');
167
- const stack = error.stack ? stripAnsi(error.stack) : null;
168
+ const toError = (error, redact, steps = [], location = error.location, runError = false) => {
169
+ const message = redact(stripAnsi(error.message ?? error.value ?? ''));
170
+ const stack = error.stack ? redact(stripAnsi(error.stack)) : null;
168
171
  const category = categorizer.categorize(errorText(message, stack, steps, runError));
169
172
  categories.add(category);
170
173
  return {
@@ -176,17 +179,21 @@ export async function buildReport(config, suite, result, options) {
176
179
  category,
177
180
  };
178
181
  };
179
- const toStep = (step, parents) => {
180
- const chain = [...parents, step.title];
182
+ const toStep = (step, parents, redact) => {
183
+ const title = redact.title(step);
184
+ const chain = [...parents, title];
185
+ // `subtitle` exists from Playwright 1.63. Before, the target of an action is in
186
+ // the title on 1.52 (`locator.getByRole('button').click`), and nowhere on
187
+ // 1.53–1.62 (`Click`, `Fill "<value>"`).
188
+ const subtitle = step.subtitle;
181
189
  return {
182
- title: step.title,
183
- // `subtitle` exists from Playwright 1.61; older versions keep the target in the title.
184
- subtitle: step.subtitle ?? null,
190
+ title,
191
+ subtitle: subtitle === undefined ? null : redact.text(subtitle),
185
192
  category: step.category,
186
193
  durationMs: toMs(step.duration),
187
- error: step.error ? toError(step.error, chain) : null,
194
+ error: step.error ? toError(step.error, redact.text, chain) : null,
188
195
  location: toLocation(step.location),
189
- steps: step.steps.map((child) => toStep(child, chain)),
196
+ steps: step.steps.map((child) => toStep(child, chain, redact)),
190
197
  };
191
198
  };
192
199
  const toAttempt = async (attempt, folder) => {
@@ -194,14 +201,15 @@ export async function buildReport(config, suite, result, options) {
194
201
  for (const attachment of attempt.attachments) {
195
202
  attachments.push(await store.add(`${folder}/${attempt.retry}`, attachment));
196
203
  }
204
+ const redact = redactor.attempt(attempt.steps);
197
205
  return {
198
206
  index: attempt.retry,
199
207
  status: attempt.status,
200
208
  startedAt: attempt.startTime.toISOString(),
201
209
  durationMs: toMs(attempt.duration),
202
210
  workerIndex: attempt.workerIndex,
203
- errors: attempt.errors.map((error) => toError(error, failingSteps(attempt.steps, error))),
204
- steps: attempt.steps.map((step) => toStep(step, [])),
211
+ errors: attempt.errors.map((error) => toError(error, redact.text, failingSteps(attempt.steps, error, redact.title))),
212
+ steps: attempt.steps.map((step) => toStep(step, [], redact)),
205
213
  attachments,
206
214
  annotations: attempt.annotations.map(toAnnotation),
207
215
  stdout: collectOutput(attempt.stdout),
@@ -292,9 +300,9 @@ export async function buildReport(config, suite, result, options) {
292
300
  for (const { error, project } of options.runErrors ?? []) {
293
301
  const message = stripAnsi(error.message ?? error.value ?? '');
294
302
  const location = isUsableLocation(error.location) ? error.location : ((await esmImportLocation(message)) ?? undefined);
295
- errors.push({ ...toError(error, [], location, true), project });
303
+ errors.push({ ...toError(error, redactor.text, [], location, true), project });
296
304
  }
297
- return {
305
+ const report = {
298
306
  schemaVersion: 1,
299
307
  generator: { name: 'playwright-loupe', version: options.generatorVersion },
300
308
  run: {
@@ -318,4 +326,6 @@ export async function buildReport(config, suite, result, options) {
318
326
  categories: categorizer.definitions(categories),
319
327
  dimensions: dimensionSet.definitions(usedDimensions),
320
328
  };
329
+ // URL parameters and env values (redact.env) are redacted in every other string: test titles, annotations, outputs, attachment bodies...
330
+ return redactor.rewritesStrings ? mapReportStrings(report, redactor.strings) : report;
321
331
  }
@@ -1,14 +1,7 @@
1
1
  import { NEUTRAL_ENTRY, PALETTE_ENTRIES, paletteReference } from '../shared/palette.js';
2
+ import { httpStatus } from './http.js';
2
3
  const ALL_TARGETS = ['message', 'stack', 'step'];
3
4
  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
- }
12
5
  export const BUILT_IN_RULES = [
13
6
  {
14
7
  // Before "Locator not found", "Assertion" and "Timeout": a locator matching
@@ -30,7 +23,7 @@ export const BUILT_IN_RULES = [
30
23
  // Before "Network", "Assertion" and "Timeout": the status is the diagnosis,
31
24
  // whether it surfaces through `failOnStatusCode`, `toBeOK()` or a thrown
32
25
  // error. A bare 429 is not enough (`429ms`, `Received: 429`): it needs an
33
- // HTTP context.
26
+ // HTTP context (src/core/http.ts), shared with error signatures.
34
27
  name: 'Rate limited',
35
28
  color: paletteReference('teal'),
36
29
  match: [/\bToo Many Requests\b/i, /\brate[- ]?limit/i, httpStatus('429')],
@@ -39,10 +32,8 @@ export const BUILT_IN_RULES = [
39
32
  {
40
33
  name: 'Server error',
41
34
  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
- ],
35
+ // In an HTTP context, reason phrase included: `500 Internal Server Error` (failOnStatusCode).
36
+ match: [httpStatus('5\\d\\d')],
46
37
  targets: ['message', 'stack'],
47
38
  },
48
39
  {
@@ -0,0 +1,21 @@
1
+ /**
2
+ * HTTP status codes in context, shared by the "Rate limited" and "Server
3
+ * error" categories and by error signatures, so both agree on what an HTTP
4
+ * status is. See "Error categories" and "Error signature" in docs/schema/v1.md.
5
+ *
6
+ * A code is in an HTTP context when it is:
7
+ * - preceded by `status`, `status code`, `statusCode`, `"status":`, `HTTP`,
8
+ * `HTTP/1.1`, `error`, or `←` (the response line of Playwright's fetch log),
9
+ * with an optional `:` or `=`: `status 429`, `Response status: 503`,
10
+ * `HTTP 500`, `statusCode=502`, `with error 401`;
11
+ * - or followed by its reason phrase: `401 Unauthorized`, `500 Internal Server Error`.
12
+ *
13
+ * A bare number is not enough: `Timeout 429ms exceeded` and `Expected: 200
14
+ * Received: 500` have no HTTP context.
15
+ */
16
+ /** Any status code from 100 to 599. */
17
+ export declare const STATUS_CODE = "[1-5]\\d\\d";
18
+ /** Source of a pattern matching `code` (a regex source such as `429` or `5\\d\\d`) in an HTTP context, case-insensitive. */
19
+ export declare function httpStatusSource(code: string): string;
20
+ /** `code` in an HTTP context. */
21
+ export declare function httpStatus(code: string): RegExp;
@@ -0,0 +1,64 @@
1
+ /**
2
+ * HTTP status codes in context, shared by the "Rate limited" and "Server
3
+ * error" categories and by error signatures, so both agree on what an HTTP
4
+ * status is. See "Error categories" and "Error signature" in docs/schema/v1.md.
5
+ *
6
+ * A code is in an HTTP context when it is:
7
+ * - preceded by `status`, `status code`, `statusCode`, `"status":`, `HTTP`,
8
+ * `HTTP/1.1`, `error`, or `←` (the response line of Playwright's fetch log),
9
+ * with an optional `:` or `=`: `status 429`, `Response status: 503`,
10
+ * `HTTP 500`, `statusCode=502`, `with error 401`;
11
+ * - or followed by its reason phrase: `401 Unauthorized`, `500 Internal Server Error`.
12
+ *
13
+ * A bare number is not enough: `Timeout 429ms exceeded` and `Expected: 200
14
+ * Received: 500` have no HTTP context.
15
+ */
16
+ const PREFIX = `(?:\\b(?:status(?:[ _]?code)?["']?\\s*[:=]?|HTTP(?:/\\d(?:\\.\\d)?)?:?|error\\s*[:=]?)|←)`;
17
+ /** Standard reason phrases (RFC 9110, plus 429). */
18
+ const REASON_PHRASES = [
19
+ 'Continue',
20
+ 'Switching Protocols',
21
+ 'OK',
22
+ 'Created',
23
+ 'Accepted',
24
+ 'No Content',
25
+ 'Moved Permanently',
26
+ 'Found',
27
+ 'See Other',
28
+ 'Not Modified',
29
+ 'Temporary Redirect',
30
+ 'Permanent Redirect',
31
+ 'Bad Request',
32
+ 'Unauthorized',
33
+ 'Payment Required',
34
+ 'Forbidden',
35
+ 'Not Found',
36
+ 'Method Not Allowed',
37
+ 'Not Acceptable',
38
+ 'Request Time-?out',
39
+ 'Conflict',
40
+ 'Gone',
41
+ 'Precondition Failed',
42
+ 'Payload Too Large',
43
+ 'Content Too Large',
44
+ 'Unsupported Media Type',
45
+ 'Unprocessable Entity',
46
+ 'Unprocessable Content',
47
+ 'Too Many Requests',
48
+ 'Internal Server Error',
49
+ 'Not Implemented',
50
+ 'Bad Gateway',
51
+ 'Service Unavailable',
52
+ 'Gateway Time-?out',
53
+ 'HTTP Version Not Supported',
54
+ ];
55
+ /** Any status code from 100 to 599. */
56
+ export const STATUS_CODE = '[1-5]\\d\\d';
57
+ /** Source of a pattern matching `code` (a regex source such as `429` or `5\\d\\d`) in an HTTP context, case-insensitive. */
58
+ export function httpStatusSource(code) {
59
+ return `${PREFIX}\\s*${code}(?!\\w|\\.\\d)|\\b${code} (?:${REASON_PHRASES.join('|')})\\b`;
60
+ }
61
+ /** `code` in an HTTP context. */
62
+ export function httpStatus(code) {
63
+ return new RegExp(httpStatusSource(code), 'i');
64
+ }