@qartisan/playwright-loupe 0.1.0 → 0.1.2

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,39 @@ 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.2] - 2026-10-21
8
+
9
+ ### Security
10
+
11
+ - **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.
12
+ - **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).
13
+ - **HTTP headers** (on by default): `cookie`, `set-cookie`, `authorization`, `proxy-authorization` and any header with a secret-looking name, in call logs (`- cookie: [redacted]`).
14
+ - **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]`.
15
+ - **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.
16
+ - 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.
17
+ - **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.
18
+
19
+ ### Changed
20
+
21
+ - **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).
22
+ - **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).
23
+
24
+ ### Fixed
25
+
26
+ - 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.
27
+
28
+ ## [0.1.1] - 2026-10-18
29
+
30
+ ### Added
31
+
32
+ - **Tags and dimension values in the Tests view.** Each row shows, under the test title, what the test covers as small neutral chips: its dimension values first (dimensions in config order, annotation-based ones included, then values in the test's order; the chip shows the value, `api`, and its tooltip and accessible name the dimension, "Layer: api"), then its free tags with their `@`, sorted. As in the filters, a tag a dimension takes appears only as that dimension's value. Each chip is a link that adds its value to the filters, keeping the others and the sort; a chip whose value is already filtered is shown active (inverted, with a ×) and removes it ("Remove filter Layer: api"). Both add a history entry, so Back undoes them. A row shows up to four chips, then a "+N" button (`aria-expanded`) that shows the rest in place; that state is not in the URL. Active chips are never hidden behind "+N": they are always shown, the other slots filled in the normal order, and "+N" counts only the chips actually hidden. The chips of each test are computed once per report. No chip line for a test without tags or dimensions. Other annotations stay in the test detail.
33
+ - `npm run perf:sample`: writes 5,000-test reports from the renderer sample (`loupe-perf/`, gitignored) and, with `-- --measure`, times the Tests view (opening, filtering, sorting, searching) in Chromium, optionally against another renderer build (`--renderer`), so performance measurements are reproducible.
34
+
35
+ ### Changed
36
+
37
+ - **Search covers tags and dimension values.** The search (`q`, in the sidebar) now also matches every raw tag of a test, those a dimension takes included (`smoke`, `@smoke` and `layer:api` all match), and its dimension values, annotation-based ones included (`BUG-42`). Same rules as before: case- and accent-insensitive, every word must match, as substrings.
38
+ - **Trace command: absolute path over `file://`.** When the report is opened from disk, "Copy trace command" copies the absolute path of the trace, computed from the report's own location (`npx playwright show-trace '/home/me/app/loupe-report/data/…/trace.zip'`), so the command works from any directory. Its help now says to run it from your project root, so it uses the project's own Playwright: the same rule as the rerun commands. The path is decoded (spaces, accented characters) before POSIX quoting; a Windows drive path loses the leading `/` of its URL form (commands stay POSIX-only). Over `http(s)` and for a `file://` URL with a host (a network share), the disk path is unknown: the command keeps the path relative to the report folder and the former help. The "Download trace" link is unchanged.
39
+
7
40
  ## [0.1.0] - 2026-10-18
8
41
 
9
42
  ### Added
package/README.md CHANGED
@@ -45,14 +45,14 @@ The report is a single self-contained HTML file. It works offline, straight from
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.
46
46
  - **Screenshots** as thumbnails. A click (or Enter / Space) opens them in a viewer, after the comparisons: fitted to the window, or at actual size by clicking the image; previous / next with the buttons or `←` / `→`; "Open original" in a new tab. Escape, the close button or a click outside closes it, back on the same thumbnail. A missing file shows its name and path.
47
47
  - **Videos** play inline. A missing or unplayable file shows its path instead of an empty player.
48
- - **Traces**: "Copy trace command" copies `npx playwright show-trace <path>`, to run from the report folder (the one containing `index.html`) in a POSIX shell. The trace viewer needs an HTTP server, so it cannot open from `file://`; the command uses the project's own Playwright, which must be at least the version that ran the tests (an older trace viewer shows an empty screen on a newer trace). The card says which version, and has a "Download trace" link.
48
+ - **Traces**: "Copy trace command" copies `npx playwright show-trace <path>`, to run from your project root in a POSIX shell, like the rerun commands. When the report is opened from disk (`file://`), the path is the absolute path of the trace, so the command works from any directory; when the report is served over HTTP, the disk path is unknown and the path is relative to the report folder (the one containing `index.html`): run it from there. The trace viewer needs an HTTP server, so it cannot open from `file://`; the command uses the project's own Playwright, which must be at least the version that ran the tests (an older trace viewer shows an empty screen on a newer trace). The card says which version, and has a "Download trace" link.
49
49
  - Other files are listed as links; small text bodies inline, collapsed.
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
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".
55
- - **Sidebar:** search (`/` to focus), projects, folders, files, `describe` blocks and tests, with status icons, failure counts and keyboard navigation. The folder shared by every file is not repeated.
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
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.
57
57
 
58
58
  ### Keyboard shortcuts
@@ -103,6 +103,21 @@ Options can also be passed inline (`['@qartisan/playwright-loupe', { title: 'Nig
103
103
 
104
104
  Every option, the lookup rules and the environment variables are documented in [`docs/config.md`](docs/config.md).
105
105
 
106
+ ### Security
107
+
108
+ Reports are meant to be shared, so Loupe removes secrets from `report.json` and `index.html` before writing them, replacing each with `[redacted]`:
109
+
110
+ - **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;
111
+ - **HTTP header values** printed in call logs (`cookie`, `set-cookie`, `authorization`, and any header with a secret-looking name);
112
+ - **URL parameters** with a secret-looking name, wherever URLs appear (navigation steps, call logs, error messages): `?token=[redacted]`, `&access_token=[redacted]`;
113
+ - 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.
114
+
115
+ ```ts
116
+ redact: { env: ['E2E_PASSWORD', 'API_TOKEN'] },
117
+ ```
118
+
119
+ **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).
120
+
106
121
  ## Compatibility
107
122
 
108
123
  | | Supported | Tested in CI |
@@ -123,6 +138,7 @@ Node.js 22.12 is the minimum because ES module config files are loaded with `req
123
138
  | `npm test` | Builds, then runs the Vitest unit tests and the integration tests (fixture suite, and a sharded run merged with `merge-reports`). |
124
139
  | `npm run fixtures` | Builds, then runs the fixture suite into `loupe-report/`. Its failures are intentional, see [`fixtures/README.md`](fixtures/README.md). |
125
140
  | `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. Its attachment files go to `src/renderer/dev/data/` (gitignored). |
141
+ | `npm run perf:sample` | Builds, then writes two 5,000-test reports to `loupe-perf/` (gitignored), from the renderer sample: `sample` (its own tags) and `heavy` (7 to 8 chips per row). Add `-- --measure` to time opening, filtering, sorting and searching the Tests view in Chromium (median of 7 runs), and `--renderer <index.html>` to time another renderer build, for a before / after comparison. |
126
142
  | `npm run dev:renderer` | Vite dev server for the renderer, fed with `src/renderer/dev/sample.json`, with hot reload; it serves the sample's attachments at `data/`. No Playwright run needed for UI work. |
127
143
 
128
144
  Tests that drive a browser need Chromium: `npx playwright install chromium`.
@@ -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
+ }