@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.
@@ -0,0 +1,101 @@
1
+ import type { RedactMode } from '../config/types.js';
2
+ /**
3
+ * Secret redaction, applied by the core before signatures and categories are
4
+ * computed and before anything is written. Three mechanisms (see "Secret
5
+ * redaction" in docs/config.md):
6
+ *
7
+ * - A, input steps: the value typed by `fill`, `type`, `pressSequentially`,
8
+ * `keyboard.type` or `keyboard.insertText` is replaced when the target
9
+ * (step subtitle, from Playwright 1.63) or the source line of the call has a
10
+ * secret-looking name. The same value is then redacted in the attempt's
11
+ * errors (Playwright repeats it in the call log: `fill("<value>")`).
12
+ * - C, HTTP headers and URL parameters: values of header lines with a
13
+ * secret-looking name, as Playwright prints them in call logs
14
+ * (`- cookie: <value>`), and of URL query or fragment parameters with a
15
+ * secret-looking name (`?token=<value>`), wherever URLs appear.
16
+ * - B, environment values: the values of the variables listed in
17
+ * `redact.env`, everywhere in the report except snippets and attachment paths.
18
+ *
19
+ * A and C share one list of secret-looking names. Attachment files (traces,
20
+ * screenshots, videos) are never altered.
21
+ */
22
+ /** What replaces a secret. */
23
+ export declare const REDACTED = "[redacted]";
24
+ /** Values shorter than this are only redacted in quoted form (A), or not used at all (B): too many false matches. */
25
+ export declare const MIN_SECRET_LENGTH = 6;
26
+ /**
27
+ * Default secret-looking names, for A (locators, source lines) and C (header
28
+ * and URL parameter names). A string matches whole words, see `createNameMatcher`; a RegExp is
29
+ * tested as is.
30
+ */
31
+ export declare const DEFAULT_SECRET_NAMES: ReadonlyArray<string | RegExp>;
32
+ /**
33
+ * Lowercase words of a name or a line: split on anything but ASCII letters and
34
+ * digits, and on camelCase boundaries (`passwordInput` → password, input;
35
+ * `APIKey` → api, key).
36
+ */
37
+ export declare function words(text: string): string[];
38
+ /**
39
+ * Tells whether a text has a secret-looking name. A string pattern matches
40
+ * consecutive whole words, case-insensitively, each word allowing a trailing
41
+ * "s": `auth` matches `x-auth-token` and `authToken` but not `Author`; `pin`
42
+ * matches `PIN code` but not `spinner`; `api key` matches `x-api-key`, `apiKey`
43
+ * and `API_KEY`. A RegExp is tested on the text as is.
44
+ */
45
+ export declare function createNameMatcher(patterns: ReadonlyArray<string | RegExp>): (text: string) => boolean;
46
+ export interface InputStep {
47
+ /** The title with the value replaced by `[redacted]`. */
48
+ redacted: string;
49
+ value: string;
50
+ /** Target written in the title (Playwright 1.52), null otherwise. */
51
+ target: string | null;
52
+ }
53
+ export declare function parseInputStep(title: string): InputStep | null;
54
+ /** The part of a Playwright step redaction reads. */
55
+ export interface StepLike {
56
+ title: string;
57
+ category: string;
58
+ location?: {
59
+ file: string;
60
+ line: number;
61
+ } | undefined;
62
+ steps: readonly StepLike[];
63
+ }
64
+ export interface RedactOptions {
65
+ /** `extend`: `patterns` add to the default names. `replace`: only `patterns`; `[]` turns A and C off. */
66
+ mode: RedactMode;
67
+ patterns: ReadonlyArray<string | RegExp>;
68
+ /** Values of the `redact.env` variables, already filtered by `secretValues`. */
69
+ values: readonly string[];
70
+ /** Reads a source file; null when it cannot be read. Default: from disk. */
71
+ readSource?: (file: string) => string | null;
72
+ }
73
+ export declare const DEFAULT_REDACT: RedactOptions;
74
+ /** Redaction for the steps and errors of one attempt. */
75
+ export interface AttemptRedactor {
76
+ /** A step title: input value (A), values typed in this attempt, URL parameters (C), env values (B). */
77
+ title(step: StepLike): string;
78
+ /** An error message or stack, a subtitle: values typed in this attempt, header values and URL parameters (C), env values (B). */
79
+ text(text: string): string;
80
+ }
81
+ export interface Redactor {
82
+ /** Finds the secret input steps of an attempt (A) and returns the redaction for its steps and errors. */
83
+ attempt(steps: readonly StepLike[]): AttemptRedactor;
84
+ /** Text outside any attempt (run errors): header values and URL parameters (C), env values (B). */
85
+ text(text: string): string;
86
+ /** Env values only (B). */
87
+ values(text: string): string;
88
+ /** URL parameters (C) and env values (B), for every other string of the report: outputs, annotations, test titles... */
89
+ strings(text: string): string;
90
+ /** False when `strings` never changes anything (no names and no env values): the report needs no rewriting pass. */
91
+ rewritesStrings: boolean;
92
+ }
93
+ export declare function createRedactor(options?: RedactOptions): Redactor;
94
+ /**
95
+ * Values of the `redact.env` variables. A variable that is unset or empty, or
96
+ * whose value is shorter than MIN_SECRET_LENGTH, is left out with a warning
97
+ * that names the variable, never its value.
98
+ */
99
+ export declare function secretValues(names: readonly string[], env: Readonly<Record<string, string | undefined>>, warn: (message: string) => void): string[];
100
+ /** Applies `replace` to every string of a JSON value, except under KEPT_KEYS. Returns a copy. */
101
+ export declare function mapReportStrings<T>(value: T, replace: (text: string) => string): T;
@@ -0,0 +1,254 @@
1
+ import { readFileSync } from 'node:fs';
2
+ /**
3
+ * Secret redaction, applied by the core before signatures and categories are
4
+ * computed and before anything is written. Three mechanisms (see "Secret
5
+ * redaction" in docs/config.md):
6
+ *
7
+ * - A, input steps: the value typed by `fill`, `type`, `pressSequentially`,
8
+ * `keyboard.type` or `keyboard.insertText` is replaced when the target
9
+ * (step subtitle, from Playwright 1.63) or the source line of the call has a
10
+ * secret-looking name. The same value is then redacted in the attempt's
11
+ * errors (Playwright repeats it in the call log: `fill("<value>")`).
12
+ * - C, HTTP headers and URL parameters: values of header lines with a
13
+ * secret-looking name, as Playwright prints them in call logs
14
+ * (`- cookie: <value>`), and of URL query or fragment parameters with a
15
+ * secret-looking name (`?token=<value>`), wherever URLs appear.
16
+ * - B, environment values: the values of the variables listed in
17
+ * `redact.env`, everywhere in the report except snippets and attachment paths.
18
+ *
19
+ * A and C share one list of secret-looking names. Attachment files (traces,
20
+ * screenshots, videos) are never altered.
21
+ */
22
+ /** What replaces a secret. */
23
+ export const REDACTED = '[redacted]';
24
+ /** Values shorter than this are only redacted in quoted form (A), or not used at all (B): too many false matches. */
25
+ export const MIN_SECRET_LENGTH = 6;
26
+ /**
27
+ * Default secret-looking names, for A (locators, source lines) and C (header
28
+ * and URL parameter names). A string matches whole words, see `createNameMatcher`; a RegExp is
29
+ * tested as is.
30
+ */
31
+ export const DEFAULT_SECRET_NAMES = [
32
+ // As a substring: newPassword, passwordConfirm, Passphrase, passwd.
33
+ /pass(?:word|wd|phrase)/i,
34
+ 'pwd',
35
+ 'passcode',
36
+ 'secret',
37
+ 'token',
38
+ 'otp',
39
+ 'totp',
40
+ 'pin',
41
+ 'api key',
42
+ 'apikey',
43
+ 'private key',
44
+ 'credential',
45
+ 'cookie',
46
+ 'session',
47
+ 'authorization',
48
+ 'auth',
49
+ 'bearer',
50
+ 'jwt',
51
+ 'csrf',
52
+ 'xsrf',
53
+ ];
54
+ /**
55
+ * Lowercase words of a name or a line: split on anything but ASCII letters and
56
+ * digits, and on camelCase boundaries (`passwordInput` → password, input;
57
+ * `APIKey` → api, key).
58
+ */
59
+ export function words(text) {
60
+ return text
61
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
62
+ .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
63
+ .toLowerCase()
64
+ .split(/[^a-z0-9]+/)
65
+ .filter((word) => word !== '');
66
+ }
67
+ /**
68
+ * Tells whether a text has a secret-looking name. A string pattern matches
69
+ * consecutive whole words, case-insensitively, each word allowing a trailing
70
+ * "s": `auth` matches `x-auth-token` and `authToken` but not `Author`; `pin`
71
+ * matches `PIN code` but not `spinner`; `api key` matches `x-api-key`, `apiKey`
72
+ * and `API_KEY`. A RegExp is tested on the text as is.
73
+ */
74
+ export function createNameMatcher(patterns) {
75
+ const regexps = patterns.filter((pattern) => pattern instanceof RegExp);
76
+ const sequences = patterns.filter((pattern) => typeof pattern === 'string').map(words).filter((list) => list.length > 0);
77
+ if (regexps.length === 0 && sequences.length === 0)
78
+ return () => false;
79
+ return (text) => {
80
+ if (regexps.some((pattern) => pattern.test(text)))
81
+ return true;
82
+ if (sequences.length === 0)
83
+ return false;
84
+ const textWords = words(text);
85
+ return sequences.some((sequence) => textWords.some((_, start) => sequence.every((word, offset) => {
86
+ const candidate = textWords[start + offset];
87
+ return candidate === word || candidate === `${word}s`;
88
+ })));
89
+ };
90
+ }
91
+ /**
92
+ * Value input steps, as Playwright titles them:
93
+ * - 1.53+: `Fill "<value>"`, `Type "<value>"` (`type`, `pressSequentially`,
94
+ * `keyboard.type`), `Insert "<value>"` (`keyboard.insertText`). Quotes in
95
+ * the value are not escaped, so the value runs to the last quote; newlines
96
+ * are written `\n`.
97
+ * - 1.52: `locator.fill(<value>)`, `locator.getByLabel('Password').type(<value>)`,
98
+ * `locator.getByLabel('Password').pressSequentially(<value>)`,
99
+ * `keyboard.type(<value>)`, `keyboard.insertText(<value>)`. The target is
100
+ * part of the title for `type` and `pressSequentially` only.
101
+ */
102
+ const INPUT_TITLE = /^(Fill|Type|Insert) "([\s\S]*)"$/;
103
+ const LEGACY_INPUT_TITLE = /^((?:locator|page|frame|keyboard|elementHandle)\b.*?)\.(fill|type|pressSequentially|insertText)\(([\s\S]*)\)$/;
104
+ export function parseInputStep(title) {
105
+ const current = INPUT_TITLE.exec(title);
106
+ if (current)
107
+ return { redacted: `${current[1]} "${REDACTED}"`, value: current[2], target: null };
108
+ const legacy = LEGACY_INPUT_TITLE.exec(title);
109
+ if (legacy) {
110
+ const [, target, action, value] = legacy;
111
+ return { redacted: `${target}.${action}(${REDACTED})`, value, target };
112
+ }
113
+ return null;
114
+ }
115
+ /**
116
+ * A header line as Playwright prints it in a call log (` - cookie: <value>`),
117
+ * or a bare `Name: value` line. Header names are tokens: no spaces.
118
+ */
119
+ const HEADER_LINE = /^([ \t]*(?:-[ \t]+)?)([A-Za-z0-9!#$%&'*+.^_`|~-]+):[ \t]+\S.*$/gm;
120
+ /**
121
+ * A URL query or fragment parameter: `?token=<value>`, `&access_token=<value>`,
122
+ * `#access_token=<value>` (OAuth implicit flow). Only after `?`, `&` or `#`, so
123
+ * a bare `token=abc` in prose is left alone. The value stops at the next
124
+ * parameter, the fragment, whitespace, a quote, a bracket or a parenthesis.
125
+ */
126
+ const URL_PARAM = /([?&#])([A-Za-z0-9_.~%-]+)=(\[redacted\]|[^&#\s"'`<>()[\]]+)/g;
127
+ function escapeRegExp(text) {
128
+ return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
129
+ }
130
+ /**
131
+ * Replaces every occurrence of `values` with `[redacted]`, longest first. An
132
+ * existing `[redacted]` is left alone, so applying it twice changes nothing
133
+ * even when a value is part of the word "redacted".
134
+ */
135
+ function valueReplacer(values) {
136
+ const unique = [...new Set(values)].filter((value) => value !== '').sort((a, b) => b.length - a.length);
137
+ if (unique.length === 0)
138
+ return (text) => text;
139
+ const pattern = new RegExp([escapeRegExp(REDACTED), ...unique.map(escapeRegExp)].join('|'), 'g');
140
+ return (text) => text.replace(pattern, REDACTED);
141
+ }
142
+ export const DEFAULT_REDACT = { mode: 'extend', patterns: [], values: [] };
143
+ function readFromDisk(file) {
144
+ try {
145
+ return readFileSync(file, 'utf8');
146
+ }
147
+ catch {
148
+ return null;
149
+ }
150
+ }
151
+ export function createRedactor(options = DEFAULT_REDACT) {
152
+ const namePatterns = options.mode === 'extend' ? [...DEFAULT_SECRET_NAMES, ...options.patterns] : options.patterns;
153
+ const names = createNameMatcher(namePatterns);
154
+ const envValues = valueReplacer(options.values);
155
+ const readSource = options.readSource ?? readFromDisk;
156
+ const sources = new Map();
157
+ /** The source line of a step, from disk; null when the file cannot be read (merge-reports without the code). */
158
+ const sourceLine = (location) => {
159
+ if (!location)
160
+ return null;
161
+ if (!sources.has(location.file))
162
+ sources.set(location.file, readSource(location.file)?.split(/\r?\n/) ?? null);
163
+ return sources.get(location.file)?.[location.line - 1] ?? null;
164
+ };
165
+ const headers = (text) => text.replace(HEADER_LINE, (line, indent, name) => (names(name) ? `${indent}${name}: ${REDACTED}` : line));
166
+ const urlParams = (text) => text.replace(URL_PARAM, (param, separator, name) => (names(name) ? `${separator}${name}=${REDACTED}` : param));
167
+ /** A: the target (subtitle from 1.63, title on 1.52), else the source line of the call, has a secret-looking name. */
168
+ const isSecret = (step, input) => {
169
+ const subtitle = step.subtitle;
170
+ if (typeof subtitle === 'string' && names(subtitle))
171
+ return true;
172
+ if (input.target !== null && names(input.target))
173
+ return true;
174
+ const line = sourceLine(step.location);
175
+ return line !== null && names(line);
176
+ };
177
+ return {
178
+ rewritesStrings: namePatterns.length > 0 || options.values.length > 0,
179
+ text: (text) => envValues(urlParams(headers(text))),
180
+ values: envValues,
181
+ strings: (text) => envValues(urlParams(text)),
182
+ attempt(steps) {
183
+ const titles = new Map();
184
+ const typed = new Set();
185
+ const visit = (list) => {
186
+ for (const step of list) {
187
+ const input = step.category === 'pw:api' ? parseInputStep(step.title) : null;
188
+ if (input && isSecret(step, input)) {
189
+ titles.set(step, input.redacted);
190
+ typed.add(input.value);
191
+ // Titles from 1.53 write newlines as `\n`; call logs keep the real ones.
192
+ typed.add(input.value.replaceAll('\\n', '\n'));
193
+ }
194
+ visit(step.steps);
195
+ }
196
+ };
197
+ visit(steps);
198
+ // A short value (a PIN) would also match line numbers and other text: only its quoted form is redacted.
199
+ const long = [...typed].filter((value) => value.length >= MIN_SECRET_LENGTH);
200
+ const short = [...typed].filter((value) => value !== '' && value.length < MIN_SECRET_LENGTH).map((value) => `"${value}"`);
201
+ const replaceLong = valueReplacer(long);
202
+ const shortPattern = short.length > 0 ? new RegExp(short.map(escapeRegExp).join('|'), 'g') : null;
203
+ const typedValues = (text) => {
204
+ const replaced = replaceLong(text);
205
+ return shortPattern ? replaced.replace(shortPattern, `"${REDACTED}"`) : replaced;
206
+ };
207
+ return {
208
+ title: (step) => envValues(urlParams(titles.get(step) ?? typedValues(step.title))),
209
+ text: (text) => envValues(urlParams(headers(typedValues(text)))),
210
+ };
211
+ },
212
+ };
213
+ }
214
+ /**
215
+ * Values of the `redact.env` variables. A variable that is unset or empty, or
216
+ * whose value is shorter than MIN_SECRET_LENGTH, is left out with a warning
217
+ * that names the variable, never its value.
218
+ */
219
+ export function secretValues(names, env, warn) {
220
+ const values = [];
221
+ for (const name of names) {
222
+ const value = env[name];
223
+ if (value === undefined || value === '') {
224
+ warn(`Loupe: redact.env: ${name} is not set or empty; nothing to redact for it.`);
225
+ }
226
+ else if (value.length < MIN_SECRET_LENGTH) {
227
+ warn(`Loupe: redact.env: the value of ${name} is shorter than ${MIN_SECRET_LENGTH} characters and is not redacted (too many false matches).`);
228
+ }
229
+ else {
230
+ values.push(value);
231
+ }
232
+ }
233
+ return values;
234
+ }
235
+ /**
236
+ * Keys whose strings B never rewrites: identifiers and signatures (hex that a
237
+ * short value could match), snippets (source code, already in the
238
+ * repository) and attachment paths (the files keep their names).
239
+ */
240
+ const KEPT_KEYS = new Set(['id', 'signature', 'errorSignature', 'snippet', 'path']);
241
+ /** Applies `replace` to every string of a JSON value, except under KEPT_KEYS. Returns a copy. */
242
+ export function mapReportStrings(value, replace) {
243
+ const visit = (node) => {
244
+ if (typeof node === 'string')
245
+ return replace(node);
246
+ if (Array.isArray(node))
247
+ return node.map(visit);
248
+ if (node !== null && typeof node === 'object') {
249
+ return Object.fromEntries(Object.entries(node).map(([key, child]) => [key, KEPT_KEYS.has(key) ? child : visit(child)]));
250
+ }
251
+ return node;
252
+ };
253
+ return visit(value);
254
+ }
@@ -11,6 +11,8 @@ interface ErrorWorkerInfo {
11
11
  }
12
12
  export declare class LoupeReporter implements Reporter {
13
13
  private readonly loupeConfig;
14
+ /** Values of the `redact.env` variables, read once at startup. */
15
+ private readonly secretValues;
14
16
  private readonly scopeOverride;
15
17
  private readonly runErrors;
16
18
  private config;
@@ -4,12 +4,15 @@ import path from 'node:path';
4
4
  import { loadConfig } from '../config/index.js';
5
5
  import { buildReport } from './build.js';
6
6
  import { injectData, injectSettings } from './inject.js';
7
+ import { secretValues } from './redact.js';
7
8
  import { SCOPE_ENV, parseScopeOverride } from './scope.js';
8
9
  import { generatorVersion } from './version.js';
9
10
  /** Built renderer template, shipped next to the compiled core (`dist/renderer/index.html`). */
10
11
  const TEMPLATE_URL = new URL('../renderer/index.html', import.meta.url);
11
12
  export class LoupeReporter {
12
13
  loupeConfig;
14
+ /** Values of the `redact.env` variables, read once at startup. */
15
+ secretValues;
13
16
  scopeOverride;
14
17
  runErrors = [];
15
18
  config;
@@ -20,6 +23,8 @@ export class LoupeReporter {
20
23
  // run with its message, which no later reporter hook can do.
21
24
  this.scopeOverride = parseScopeOverride(process.env[SCOPE_ENV]);
22
25
  this.loupeConfig = loadConfig(options);
26
+ // After the config is loaded; warnings name a variable, never its value.
27
+ this.secretValues = secretValues(this.loupeConfig.redact.env, process.env, (message) => console.warn(message));
23
28
  }
24
29
  printsToStdio() {
25
30
  return false;
@@ -47,6 +52,7 @@ export class LoupeReporter {
47
52
  categories: this.loupeConfig.categories,
48
53
  dimensions: this.loupeConfig.dimensions,
49
54
  runErrors: this.runErrors,
55
+ redact: { mode: this.loupeConfig.redact.mode, patterns: this.loupeConfig.redact.patterns, values: this.secretValues },
50
56
  });
51
57
  const settings = {
52
58
  title: this.loupeConfig.title,
@@ -1,4 +1,5 @@
1
1
  import { createHash } from 'node:crypto';
2
+ import { STATUS_CODE, httpStatusSource } from './http.js';
2
3
  // Same pattern as the `ansi-regex` package (CSI and OSC sequences).
3
4
  const ANSI_PATTERN = /[\u001B\u009B][[\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\d/#&.:=?%@~_]+)*|[a-zA-Z\d]+(?:;[-a-zA-Z\d/#&.:=?%@~_]*)*)?\u0007)|(?:(?:\d{1,4}(?:;\d{0,4})*)?[\dA-PR-TZcf-ntqry=><~]))/g;
4
5
  export function stripAnsi(text) {
@@ -78,8 +79,14 @@ const REPLACEMENTS = [
78
79
  [/\b\d+(?:\.\d+)?\s?(?:ms|milliseconds?|s|secs?|seconds?|m|mins?|minutes?|h|hours?)\b/gi, '<duration>'],
79
80
  // 8+ hex chars with at least one letter (pure digit runs fall through to <n>).
80
81
  [/(?<![0-9A-Za-z])(?=[0-9a-f]*[a-f])[0-9a-f]{8,}(?![0-9A-Za-z])/gi, '<hex>'],
81
- [/\d+(?:\.\d+)?/g, '<n>'],
82
82
  ];
83
+ /**
84
+ * Last replacement: remaining numbers become `<n>`, except HTTP status codes
85
+ * (100–599) in an HTTP context, kept with their context so `error 401` and
86
+ * `error 500` stay apart. Same context as the "Rate limited" and "Server
87
+ * error" categories (src/core/http.ts).
88
+ */
89
+ const STATUS_OR_NUMBER = new RegExp(`(${httpStatusSource(STATUS_CODE)})|\\d+(?:\\.\\d+)?`, 'gi');
83
90
  /**
84
91
  * Normalizes an error message so the same failure produces the same text
85
92
  * across tests, retries and runs: ANSI codes and the call log are removed,
@@ -97,6 +104,7 @@ export function normalizeErrorMessage(message, options = {}) {
97
104
  for (const [pattern, placeholder] of REPLACEMENTS) {
98
105
  text = text.replace(pattern, placeholder);
99
106
  }
107
+ text = text.replace(STATUS_OR_NUMBER, (_match, status) => status ?? '<n>');
100
108
  return text.replace(/\s+/g, ' ').trim();
101
109
  }
102
110
  /** First 12 hex chars of sha256 of the normalized message. Independent of the test. */