@sdods/core 0.3.2 → 0.5.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.
Files changed (56) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/api/client.d.ts +62 -1
  3. package/dist/api/client.js +189 -27
  4. package/dist/api/template.d.ts +15 -2
  5. package/dist/api/template.js +31 -13
  6. package/dist/auth/capture.js +4 -3
  7. package/dist/auth/index.js +3 -18
  8. package/dist/config/defaults.js +2 -0
  9. package/dist/config/resolve.d.ts +5 -1
  10. package/dist/config/resolve.js +16 -3
  11. package/dist/config/runner.d.ts +26 -1
  12. package/dist/config/runner.js +155 -24
  13. package/dist/config/tags.d.ts +14 -2
  14. package/dist/config/tags.js +27 -7
  15. package/dist/evidence/index.d.ts +3 -0
  16. package/dist/evidence/index.js +3 -0
  17. package/dist/evidence/trace-redact.d.ts +74 -0
  18. package/dist/evidence/trace-redact.js +297 -0
  19. package/dist/evidence/zip.d.ts +36 -0
  20. package/dist/evidence/zip.js +148 -0
  21. package/dist/fixtures/api-context.d.ts +7 -0
  22. package/dist/fixtures/api-context.js +7 -0
  23. package/dist/fixtures/auth.js +72 -5
  24. package/dist/fixtures/test.js +9 -2
  25. package/dist/heal/healer.d.ts +12 -1
  26. package/dist/heal/healer.js +80 -34
  27. package/dist/heal/strategies.d.ts +12 -2
  28. package/dist/heal/strategies.js +42 -0
  29. package/dist/index.d.ts +1 -0
  30. package/dist/index.js +1 -0
  31. package/dist/lint/index.d.ts +1 -0
  32. package/dist/lint/index.js +44 -3
  33. package/dist/lint/steps.d.ts +13 -0
  34. package/dist/lint/steps.js +333 -0
  35. package/dist/load-module.d.ts +7 -0
  36. package/dist/load-module.js +70 -0
  37. package/dist/logger.d.ts +1 -0
  38. package/dist/logger.js +9 -3
  39. package/dist/steps/a11y.steps.js +10 -10
  40. package/dist/steps/api.steps.js +64 -27
  41. package/dist/steps/browser.steps.js +34 -33
  42. package/dist/steps/clock.steps.js +5 -5
  43. package/dist/steps/data.steps.js +61 -8
  44. package/dist/steps/db.steps.js +8 -8
  45. package/dist/steps/dom.steps.js +49 -49
  46. package/dist/steps/hybrid.steps.js +9 -5
  47. package/dist/steps/iframe.steps.js +9 -9
  48. package/dist/steps/net.steps.d.ts +12 -7
  49. package/dist/steps/net.steps.js +78 -83
  50. package/dist/steps/perf.steps.js +6 -6
  51. package/dist/steps/tabs.steps.js +6 -6
  52. package/dist/steps/ui.steps.js +94 -45
  53. package/dist/steps/webhook.steps.js +3 -3
  54. package/dist/version.d.ts +1 -1
  55. package/dist/version.js +15 -1
  56. package/package.json +6 -4
@@ -0,0 +1,297 @@
1
+ import { readdirSync, readFileSync, renameSync, lstatSync, writeFileSync } from 'node:fs';
2
+ import { basename, join } from 'node:path';
3
+ import { SECRET_HEADER } from '../har/api-har.js';
4
+ import { entryContent, isZip, readZip, withContent, writeZip } from './zip.js';
5
+ /**
6
+ * Strip credentials out of Playwright traces (#101).
7
+ *
8
+ * A trace records every request and response header, the context's options (`extraHTTPHeaders`,
9
+ * `httpCredentials`, `storageState`), every `storageState()` / `addCookies()` call with its result,
10
+ * DOM snapshots and the arguments of every action. For a project that signs in with pool accounts
11
+ * that is the session cookie, the `Authorization` bearer and — for Firebase — the long-lived
12
+ * refresh token in localStorage/IndexedDB.
13
+ *
14
+ * What is replaced with `[redacted]`:
15
+ * - credential headers (`Cookie`, `Set-Cookie`, `Authorization`, `Proxy-Authorization`,
16
+ * `X-Api-Key` and similar) wherever a `{ name, value }` header or a header map appears —
17
+ * `.network` request/response entries, context options, route and fetch parameters;
18
+ * - every cookie value, localStorage/sessionStorage value and IndexedDB record;
19
+ * - `password` / `passphrase` fields (`httpCredentials`, `proxy`, client certificates);
20
+ * - the value of password inputs in DOM snapshots, and values typed by `fill`/`type` into a field
21
+ * whose locator names a password, secret, token or one-time code (best effort).
22
+ *
23
+ * Not covered: response and request bodies (`resources/`), page text and screenshots. A redacted
24
+ * trace is safer to keep, not safe to publish.
25
+ *
26
+ * Only `*.trace` and `*.network` entries are rewritten; everything else (sources, screenshots,
27
+ * resources, stacks) is copied through unchanged, so the trace viewer opens the result as before.
28
+ */
29
+ export const REDACTED = '[redacted]';
30
+ export const TRACE_SECRET_HEADER = new RegExp(`${SECRET_HEADER.source}|^(x-auth-token|x-access-token|x-refresh-token|x-id-token|x-session-token|x-csrf-token|x-xsrf-token|x-amz-security-token|x-goog-api-key|x-firebase-appcheck|api-key|apikey)$`, 'i');
31
+ /** A locator or selector that names a field whose typed value is a credential. */
32
+ const SECRET_FIELD = /pass(word|wd|code|phrase)?\b|passw|secret|token|\botp\b|one-time|\bpin\b/i;
33
+ const TYPING_METHODS = new Set(['fill', 'type', 'pressSequentially']);
34
+ const isObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
35
+ function blank(holder, key) {
36
+ const v = holder[key];
37
+ if (v === undefined || v === null || v === '' || v === REDACTED)
38
+ return 0;
39
+ holder[key] = REDACTED;
40
+ return 1;
41
+ }
42
+ /**
43
+ * Redact one parsed trace event in place. Returns the number of values replaced.
44
+ *
45
+ * `typed` carries values redacted from typing actions across the events of one stream, by call id,
46
+ * so the action's `log` lines (`fill("…")`) lose the value too.
47
+ */
48
+ export function redactTraceEvent(event, typed = new Map()) {
49
+ let n = walk(event, undefined);
50
+ if (!isObject(event))
51
+ return n;
52
+ const value = redactTypedSecret(event);
53
+ if (value !== undefined) {
54
+ n++;
55
+ if (typeof event.callId === 'string')
56
+ typed.set(event.callId, value);
57
+ }
58
+ if (event.type === 'log' &&
59
+ typeof event.callId === 'string' &&
60
+ typeof event.message === 'string') {
61
+ const secret = typed.get(event.callId);
62
+ if (secret && event.message.includes(secret)) {
63
+ event.message = event.message.split(secret).join(REDACTED);
64
+ n++;
65
+ }
66
+ }
67
+ return n;
68
+ }
69
+ function walk(node, key) {
70
+ let n = 0;
71
+ if (Array.isArray(node)) {
72
+ if (key === 'cookies' || key === 'localStorage' || key === 'sessionStorage') {
73
+ for (const item of node)
74
+ if (isObject(item))
75
+ n += blank(item, 'value');
76
+ }
77
+ if (key === 'indexedDB') {
78
+ for (const db of node) {
79
+ const stores = isObject(db) && Array.isArray(db.stores) ? db.stores : [];
80
+ for (const store of stores) {
81
+ const records = isObject(store) && Array.isArray(store.records) ? store.records : [];
82
+ for (const record of records) {
83
+ if (!isObject(record))
84
+ continue;
85
+ for (const f of ['key', 'keyEncoded', 'value', 'valueEncoded'])
86
+ n += blank(record, f);
87
+ }
88
+ }
89
+ }
90
+ }
91
+ // DOM snapshot node: ["INPUT", { type: "password", __playwright_value_: "..." }, ...children]
92
+ if (typeof node[0] === 'string' &&
93
+ node[0].toUpperCase() === 'INPUT' &&
94
+ isObject(node[1]) &&
95
+ String(node[1].type ?? '').toLowerCase() === 'password') {
96
+ n += blank(node[1], '__playwright_value_') + blank(node[1], 'value');
97
+ }
98
+ for (const item of node)
99
+ n += walk(item, undefined);
100
+ return n;
101
+ }
102
+ if (!isObject(node))
103
+ return 0;
104
+ if (typeof node.name === 'string' && 'value' in node && TRACE_SECRET_HEADER.test(node.name))
105
+ n += blank(node, 'value');
106
+ if (key !== undefined && /headers$/i.test(key)) {
107
+ for (const [header, value] of Object.entries(node))
108
+ if (typeof value === 'string' && TRACE_SECRET_HEADER.test(header))
109
+ n += blank(node, header);
110
+ }
111
+ for (const f of ['password', 'passphrase'])
112
+ if (typeof node[f] === 'string')
113
+ n += blank(node, f);
114
+ for (const [k, v] of Object.entries(node))
115
+ if (typeof v === 'object')
116
+ n += walk(v, k);
117
+ return n;
118
+ }
119
+ /** Returns the typed value when it was redacted. */
120
+ function redactTypedSecret(event) {
121
+ if (event.type !== 'before' || !isObject(event.params))
122
+ return undefined;
123
+ const params = event.params;
124
+ if (typeof params.value !== 'string' || !params.value || params.value === REDACTED)
125
+ return undefined;
126
+ const method = String(event.method ?? '');
127
+ const title = typeof event.title === 'string' ? event.title : '';
128
+ const typing = TYPING_METHODS.has(method) ||
129
+ (method === 'pw:api' && /^(fill|type|press sequentially)\b/i.test(title));
130
+ if (!typing)
131
+ return undefined;
132
+ const target = [params.selector, params.locator, event.subtitle]
133
+ .filter((s) => typeof s === 'string')
134
+ .join(' ');
135
+ if (!SECRET_FIELD.test(target))
136
+ return undefined;
137
+ const typed = params.value;
138
+ params.value = REDACTED;
139
+ if (title.includes(typed))
140
+ event.title = title.split(typed).join(REDACTED);
141
+ return typed;
142
+ }
143
+ /** Redact every JSON line of a `.trace` / `.network` entry. Unparseable lines are kept as they are. */
144
+ export function redactTraceJsonl(text) {
145
+ let changed = 0;
146
+ const typed = new Map();
147
+ const lines = text.split('\n').map((line) => {
148
+ if (!line.trim())
149
+ return line;
150
+ let event;
151
+ try {
152
+ event = JSON.parse(line);
153
+ }
154
+ catch {
155
+ return line;
156
+ }
157
+ const n = redactTraceEvent(event, typed);
158
+ if (!n)
159
+ return line;
160
+ changed += n;
161
+ return JSON.stringify(event);
162
+ });
163
+ return { text: changed ? lines.join('\n') : text, changed };
164
+ }
165
+ const isTraceEvents = (name) => /\.(trace|network)$/.test(name);
166
+ /** True when the buffer is a zip holding a Playwright trace (or a zip that nests one). */
167
+ export function isTraceZip(entries) {
168
+ return entries.some((e) => isTraceEvents(e.name));
169
+ }
170
+ /**
171
+ * Redact a trace zip held in memory. Zips nested inside (blob shard reports keep attachments as
172
+ * `resources/<sha>.zip`) are redacted too. Non-trace zips come back untouched with `trace: false`.
173
+ */
174
+ export function redactTraceZip(buf) {
175
+ if (!isZip(buf))
176
+ return { buffer: buf, changed: 0, trace: false };
177
+ const entries = readZip(buf);
178
+ let changed = 0;
179
+ let trace = isTraceZip(entries);
180
+ const out = entries.map((entry) => {
181
+ if (isTraceEvents(entry.name)) {
182
+ const res = redactTraceJsonl(entryContent(entry).toString('utf8'));
183
+ if (!res.changed)
184
+ return entry;
185
+ changed += res.changed;
186
+ return withContent(entry, Buffer.from(res.text, 'utf8'));
187
+ }
188
+ if (entry.name.endsWith('.zip')) {
189
+ const inner = entryContent(entry);
190
+ const res = redactTraceZip(inner);
191
+ if (res.trace)
192
+ trace = true;
193
+ if (!res.changed)
194
+ return entry;
195
+ changed += res.changed;
196
+ return withContent(entry, res.buffer);
197
+ }
198
+ return entry;
199
+ });
200
+ return { buffer: changed ? writeZip(out) : buf, changed, trace };
201
+ }
202
+ function writeAtomically(file, data) {
203
+ const tmp = join(file, '..', `.${basename(file)}.redacting-${process.pid}`);
204
+ writeFileSync(tmp, data);
205
+ renameSync(tmp, file);
206
+ }
207
+ /** Redact a trace zip on disk in place. Returns values replaced (0 when not a trace). */
208
+ export function redactTraceFile(file) {
209
+ const res = redactTraceZip(readFileSync(file));
210
+ if (res.changed)
211
+ writeAtomically(file, res.buffer);
212
+ return res.changed;
213
+ }
214
+ /**
215
+ * Cucumber messages embed each trace as a BASE64 attachment body, and ingest writes `trace.zip`
216
+ * from those bytes — so the copy in `messages*.ndjson` is redacted as well.
217
+ */
218
+ export function redactMessagesFile(file) {
219
+ const text = readFileSync(file, 'utf8');
220
+ if (!text.includes('"application/zip"'))
221
+ return 0;
222
+ let changed = 0;
223
+ const lines = text.split('\n').map((line) => {
224
+ if (!line.includes('"application/zip"'))
225
+ return line;
226
+ let env;
227
+ try {
228
+ env = JSON.parse(line);
229
+ }
230
+ catch {
231
+ return line;
232
+ }
233
+ const a = env.attachment;
234
+ if (!a || a.mediaType !== 'application/zip' || a.contentEncoding !== 'BASE64' || !a.body)
235
+ return line;
236
+ const res = redactTraceZip(Buffer.from(a.body, 'base64'));
237
+ if (!res.changed)
238
+ return line;
239
+ changed += res.changed;
240
+ a.body = res.buffer.toString('base64');
241
+ return JSON.stringify(env);
242
+ });
243
+ if (changed)
244
+ writeAtomically(file, lines.join('\n'));
245
+ return changed;
246
+ }
247
+ /**
248
+ * Redact every trace a run left behind: `runner-output/<test>/trace.zip`, the HTML report's copies
249
+ * under `html-report/data/`, blob shard reports, and the BASE64 bodies in `messages*.ndjson`.
250
+ */
251
+ export function redactRunTraces(runDir, maxDepth = 10) {
252
+ const result = { files: [], values: 0, errors: [] };
253
+ const visit = (dir, depth) => {
254
+ if (depth > maxDepth)
255
+ return;
256
+ let names;
257
+ try {
258
+ names = readdirSync(dir);
259
+ }
260
+ catch {
261
+ return;
262
+ }
263
+ for (const name of names) {
264
+ const file = join(dir, name);
265
+ let st;
266
+ try {
267
+ st = lstatSync(file);
268
+ }
269
+ catch {
270
+ continue;
271
+ }
272
+ if (st.isSymbolicLink())
273
+ continue;
274
+ if (st.isDirectory()) {
275
+ if (name !== 'node_modules')
276
+ visit(file, depth + 1);
277
+ continue;
278
+ }
279
+ const isMessages = /^messages.*\.ndjson$/.test(name);
280
+ if (!name.endsWith('.zip') && !isMessages)
281
+ continue;
282
+ try {
283
+ const n = isMessages ? redactMessagesFile(file) : redactTraceFile(file);
284
+ if (n) {
285
+ result.files.push(file);
286
+ result.values += n;
287
+ }
288
+ }
289
+ catch (e) {
290
+ result.errors.push({ file, error: e.message });
291
+ }
292
+ }
293
+ };
294
+ visit(runDir, 0);
295
+ return result;
296
+ }
297
+ //# sourceMappingURL=trace-redact.js.map
@@ -0,0 +1,36 @@
1
+ /**
2
+ * The smallest zip reader/writer that round-trips a Playwright trace.
3
+ *
4
+ * Playwright writes traces with yazl: DEFLATE entries whose local headers set the data-descriptor
5
+ * flag and leave sizes and CRC zero, so sizes are read from the central directory. Entries that are
6
+ * not rewritten are copied through compressed, byte for byte. ZIP64 archives (over 4 GB or 65,535
7
+ * entries) are refused rather than misread.
8
+ */
9
+ export interface ZipEntry {
10
+ name: string;
11
+ /** raw name bytes, preserved as written */
12
+ nameBytes: Buffer;
13
+ method: number;
14
+ flags: number;
15
+ time: number;
16
+ date: number;
17
+ crc: number;
18
+ compressedSize: number;
19
+ size: number;
20
+ versionMadeBy: number;
21
+ versionNeeded: number;
22
+ internalAttr: number;
23
+ externalAttr: number;
24
+ /** compressed payload */
25
+ data: Buffer;
26
+ }
27
+ export declare class ZipFormatError extends Error {
28
+ }
29
+ export declare function isZip(buf: Buffer): boolean;
30
+ export declare function readZip(buf: Buffer): ZipEntry[];
31
+ export declare function entryContent(entry: ZipEntry): Buffer;
32
+ /** Replace an entry's content, recompressing with DEFLATE. */
33
+ export declare function withContent(entry: ZipEntry, content: Buffer): ZipEntry;
34
+ export declare function writeZip(entries: ZipEntry[]): Buffer;
35
+ export declare function crc32(buf: Buffer): number;
36
+ //# sourceMappingURL=zip.d.ts.map
@@ -0,0 +1,148 @@
1
+ import { deflateRawSync, inflateRawSync } from 'node:zlib';
2
+ const SIG_LOCAL = 0x04034b50;
3
+ const SIG_CENTRAL = 0x02014b50;
4
+ const SIG_END = 0x06054b50;
5
+ const FLAG_DATA_DESCRIPTOR = 0x0008;
6
+ export class ZipFormatError extends Error {
7
+ }
8
+ export function isZip(buf) {
9
+ return buf.length >= 22 && buf.readUInt32LE(0) === SIG_LOCAL;
10
+ }
11
+ export function readZip(buf) {
12
+ let end = -1;
13
+ for (let i = buf.length - 22; i >= Math.max(0, buf.length - 22 - 0xffff); i--) {
14
+ if (buf.readUInt32LE(i) === SIG_END) {
15
+ end = i;
16
+ break;
17
+ }
18
+ }
19
+ if (end < 0)
20
+ throw new ZipFormatError('not a zip archive (no end of central directory)');
21
+ const count = buf.readUInt16LE(end + 10);
22
+ const cdSize = buf.readUInt32LE(end + 12);
23
+ const cdOffset = buf.readUInt32LE(end + 16);
24
+ if (count === 0xffff || cdOffset === 0xffffffff || cdSize === 0xffffffff)
25
+ throw new ZipFormatError('ZIP64 archives are not supported');
26
+ const entries = [];
27
+ let p = cdOffset;
28
+ for (let i = 0; i < count; i++) {
29
+ if (buf.readUInt32LE(p) !== SIG_CENTRAL)
30
+ throw new ZipFormatError(`bad central directory header at ${p}`);
31
+ const nameLen = buf.readUInt16LE(p + 28);
32
+ const extraLen = buf.readUInt16LE(p + 30);
33
+ const commentLen = buf.readUInt16LE(p + 32);
34
+ const localOffset = buf.readUInt32LE(p + 42);
35
+ const compressedSize = buf.readUInt32LE(p + 20);
36
+ const nameBytes = Buffer.from(buf.subarray(p + 46, p + 46 + nameLen));
37
+ if (buf.readUInt32LE(localOffset) !== SIG_LOCAL)
38
+ throw new ZipFormatError(`bad local header at ${localOffset}`);
39
+ const dataStart = localOffset + 30 + buf.readUInt16LE(localOffset + 26) + buf.readUInt16LE(localOffset + 28);
40
+ entries.push({
41
+ name: nameBytes.toString('utf8'),
42
+ nameBytes,
43
+ versionMadeBy: buf.readUInt16LE(p + 4),
44
+ versionNeeded: buf.readUInt16LE(p + 6),
45
+ flags: buf.readUInt16LE(p + 8),
46
+ method: buf.readUInt16LE(p + 10),
47
+ time: buf.readUInt16LE(p + 12),
48
+ date: buf.readUInt16LE(p + 14),
49
+ crc: buf.readUInt32LE(p + 16),
50
+ compressedSize,
51
+ size: buf.readUInt32LE(p + 24),
52
+ internalAttr: buf.readUInt16LE(p + 36),
53
+ externalAttr: buf.readUInt32LE(p + 38),
54
+ data: buf.subarray(dataStart, dataStart + compressedSize),
55
+ });
56
+ p += 46 + nameLen + extraLen + commentLen;
57
+ }
58
+ return entries;
59
+ }
60
+ export function entryContent(entry) {
61
+ if (entry.method === 0)
62
+ return Buffer.from(entry.data);
63
+ if (entry.method === 8)
64
+ return inflateRawSync(entry.data);
65
+ throw new ZipFormatError(`unsupported compression method ${entry.method} for ${entry.name}`);
66
+ }
67
+ /** Replace an entry's content, recompressing with DEFLATE. */
68
+ export function withContent(entry, content) {
69
+ const data = deflateRawSync(content);
70
+ return {
71
+ ...entry,
72
+ method: 8,
73
+ versionNeeded: Math.max(entry.versionNeeded, 20),
74
+ crc: crc32(content),
75
+ size: content.length,
76
+ compressedSize: data.length,
77
+ data,
78
+ };
79
+ }
80
+ export function writeZip(entries) {
81
+ const parts = [];
82
+ const central = [];
83
+ let offset = 0;
84
+ for (const e of entries) {
85
+ // Sizes and CRC are known, so the local header carries them and no data descriptor follows.
86
+ const flags = e.flags & ~FLAG_DATA_DESCRIPTOR;
87
+ const local = Buffer.alloc(30);
88
+ local.writeUInt32LE(SIG_LOCAL, 0);
89
+ local.writeUInt16LE(e.versionNeeded, 4);
90
+ local.writeUInt16LE(flags, 6);
91
+ local.writeUInt16LE(e.method, 8);
92
+ local.writeUInt16LE(e.time, 10);
93
+ local.writeUInt16LE(e.date, 12);
94
+ local.writeUInt32LE(e.crc >>> 0, 14);
95
+ local.writeUInt32LE(e.compressedSize, 18);
96
+ local.writeUInt32LE(e.size, 22);
97
+ local.writeUInt16LE(e.nameBytes.length, 26);
98
+ local.writeUInt16LE(0, 28);
99
+ const cd = Buffer.alloc(46);
100
+ cd.writeUInt32LE(SIG_CENTRAL, 0);
101
+ cd.writeUInt16LE(e.versionMadeBy, 4);
102
+ cd.writeUInt16LE(e.versionNeeded, 6);
103
+ cd.writeUInt16LE(flags, 8);
104
+ cd.writeUInt16LE(e.method, 10);
105
+ cd.writeUInt16LE(e.time, 12);
106
+ cd.writeUInt16LE(e.date, 14);
107
+ cd.writeUInt32LE(e.crc >>> 0, 16);
108
+ cd.writeUInt32LE(e.compressedSize, 20);
109
+ cd.writeUInt32LE(e.size, 24);
110
+ cd.writeUInt16LE(e.nameBytes.length, 28);
111
+ cd.writeUInt16LE(0, 30);
112
+ cd.writeUInt16LE(0, 32);
113
+ cd.writeUInt16LE(0, 34);
114
+ cd.writeUInt16LE(e.internalAttr, 36);
115
+ cd.writeUInt32LE(e.externalAttr >>> 0, 38);
116
+ cd.writeUInt32LE(offset, 42);
117
+ parts.push(local, e.nameBytes, e.data);
118
+ central.push(cd, e.nameBytes);
119
+ offset += local.length + e.nameBytes.length + e.data.length;
120
+ if (offset > 0xffffffff)
121
+ throw new ZipFormatError('archive too large (ZIP64 not supported)');
122
+ }
123
+ const cdSize = central.reduce((n, b) => n + b.length, 0);
124
+ const end = Buffer.alloc(22);
125
+ end.writeUInt32LE(SIG_END, 0);
126
+ end.writeUInt16LE(entries.length, 8);
127
+ end.writeUInt16LE(entries.length, 10);
128
+ end.writeUInt32LE(cdSize, 12);
129
+ end.writeUInt32LE(offset, 16);
130
+ return Buffer.concat([...parts, ...central, end]);
131
+ }
132
+ let crcTable;
133
+ export function crc32(buf) {
134
+ if (!crcTable) {
135
+ crcTable = new Uint32Array(256);
136
+ for (let n = 0; n < 256; n++) {
137
+ let c = n;
138
+ for (let k = 0; k < 8; k++)
139
+ c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
140
+ crcTable[n] = c >>> 0;
141
+ }
142
+ }
143
+ let c = 0xffffffff;
144
+ for (let i = 0; i < buf.length; i++)
145
+ c = crcTable[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
146
+ return (c ^ 0xffffffff) >>> 0;
147
+ }
148
+ //# sourceMappingURL=zip.js.map
@@ -30,6 +30,13 @@ export declare class ApiContext {
30
30
  name: string;
31
31
  value: string;
32
32
  } | null | undefined;
33
+ /**
34
+ * Send through a request context with an empty cookie jar instead of the shared `request`
35
+ * fixture. On @ui/@hybrid the shared context starts from the leased role's storageState, so a
36
+ * "credential X alone is refused" assertion would otherwise pass on the session cookie.
37
+ * Set by `I use an isolated API client`.
38
+ */
39
+ isolated: boolean;
33
40
  /** step index → number of calls made during that step (for attachment numbering) */
34
41
  readonly callsByStep: Map<number, number>;
35
42
  get lastResponse(): ApiSnapshot['response'] | undefined;
@@ -18,6 +18,13 @@ export class ApiContext {
18
18
  * refused. Any suite whose env declared `api.auth` had a no-op step.
19
19
  */
20
20
  auth;
21
+ /**
22
+ * Send through a request context with an empty cookie jar instead of the shared `request`
23
+ * fixture. On @ui/@hybrid the shared context starts from the leased role's storageState, so a
24
+ * "credential X alone is refused" assertion would otherwise pass on the session cookie.
25
+ * Set by `I use an isolated API client`.
26
+ */
27
+ isolated = false;
21
28
  /** step index → number of calls made during that step (for attachment numbering) */
22
29
  callsByStep = new Map();
23
30
  get lastResponse() {
@@ -1,6 +1,77 @@
1
1
  import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync, } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { Logger } from '../logger.js';
4
+ function originOf(url) {
5
+ try {
6
+ const o = new URL(url).origin;
7
+ return o === 'null' ? undefined : o;
8
+ }
9
+ catch {
10
+ return undefined;
11
+ }
12
+ }
13
+ /**
14
+ * Restore one origin's localStorage into a live context without navigating (#99).
15
+ *
16
+ * This used to `page.goto(origin)` and write the keys from there. An app that sends a signed-in
17
+ * visitor away from `/` on the client then had a redirect chain still running when the step
18
+ * returned, and the scenario's first `page.goto` was aborted (`net::ERR_ABORTED`). The context
19
+ * already exists by the time a step leases a user, so `newContext({ storageState })` is not an
20
+ * option here (the `@user:<role>` tag path does use it, via `ensure`).
21
+ *
22
+ * - Page already on the origin: write the keys straight into it. Nothing navigates.
23
+ * - Otherwise: a context init script writes them in the first document of that origin, before the
24
+ * app's own scripts run, in any page of the context.
25
+ *
26
+ * Planted ONCE per context, not on every navigation. An init script left in place would run again
27
+ * on each document, so an app that signs out (clears storage) and navigates would be silently
28
+ * signed back in and a logout scenario would pass while proving nothing. The script is therefore
29
+ * disposed once a document of that origin has reached DOMContentLoaded (by then it has certainly
30
+ * run). A navigation that commits in the short window before the dispose lands can run it again;
31
+ * it only writes keys that are absent, so it never overwrites what the app wrote meanwhile.
32
+ *
33
+ * Only localStorage is restored, as before: storageState carries no sessionStorage or IndexedDB.
34
+ */
35
+ async function plantLocalStorage(context, page, origin, items) {
36
+ if (originOf(page.url()) === origin) {
37
+ await page.evaluate((entries) => {
38
+ for (const { name, value } of entries)
39
+ window.localStorage.setItem(name, value);
40
+ }, items);
41
+ return;
42
+ }
43
+ const script = await context.addInitScript(({ origin: target, entries }) => {
44
+ if (location.origin !== target)
45
+ return;
46
+ try {
47
+ for (const { name, value } of entries) {
48
+ if (window.localStorage.getItem(name) === null)
49
+ window.localStorage.setItem(name, value);
50
+ }
51
+ }
52
+ catch {
53
+ // storage disabled for this document (sandboxed frame): nothing to plant into
54
+ }
55
+ }, { origin, entries: items });
56
+ const watched = new Set();
57
+ let disposed = false;
58
+ const onLoaded = (p) => {
59
+ if (disposed || originOf(p.url()) !== origin)
60
+ return;
61
+ disposed = true;
62
+ context.off('page', watch);
63
+ for (const w of watched)
64
+ w.off('domcontentloaded', onLoaded);
65
+ void script.dispose().catch(() => undefined);
66
+ };
67
+ const watch = (p) => {
68
+ watched.add(p);
69
+ p.on('domcontentloaded', onLoaded);
70
+ };
71
+ context.on('page', watch);
72
+ for (const p of context.pages())
73
+ watch(p);
74
+ }
4
75
  /** How long another worker may hold the refresh lock before we treat it as crashed. */
5
76
  const LOCK_STALE_MS = 90_000;
6
77
  const LOCK_POLL_MS = 250;
@@ -142,11 +213,7 @@ export class AuthStateCache {
142
213
  for (const origin of state.origins ?? []) {
143
214
  if (!origin.localStorage?.length)
144
215
  continue;
145
- await page.goto(origin.origin, { waitUntil: 'domcontentloaded' }).catch(() => undefined);
146
- await page.evaluate((items) => {
147
- for (const { name, value } of items)
148
- window.localStorage.setItem(name, value);
149
- }, origin.localStorage);
216
+ await plantLocalStorage(context, page, origin.origin, origin.localStorage);
150
217
  }
151
218
  return true;
152
219
  }
@@ -3,7 +3,7 @@ import { test as base, createBdd } from 'playwright-bdd';
3
3
  import { ProjectRegistry } from '../config/registry.js';
4
4
  import { parseTagValue, scenarioSkipReason } from '../config/tags.js';
5
5
  import { noopAuth } from '../auth/index.js';
6
- import { ApiClient } from '../api/client.js';
6
+ import { ApiClient, isolatedRequestFactory } from '../api/client.js';
7
7
  import { CompositeDataProvider } from '../data/provider.js';
8
8
  import { FileUserPool } from '../data/user-pool.js';
9
9
  import { apiHarForScenario } from '../har/hooks.js';
@@ -151,9 +151,15 @@ export const test = base.extend({
151
151
  apiContext: async ({}, use) => {
152
152
  await use(new ApiContext());
153
153
  },
154
- api: async ({ request, config, apiContext, scenario, harMode, $bddContext, $tags }, use, testInfo) => {
154
+ // `request` is Playwright's shared APIRequestContext. It is seeded from the test's context
155
+ // options, so on @ui/@hybrid it carries the leased role's storageState cookies (plus any cookie
156
+ // a response sets); on @api storageState is undefined and it starts empty. The isolated context
157
+ // behind `I use an isolated API client` is only created when a scenario asks for it.
158
+ api: async ({ request, playwright, config, apiContext, scenario, harMode, $bddContext, $tags }, use, testInfo) => {
159
+ const isolated = isolatedRequestFactory(playwright.request);
155
160
  await use(new ApiClient({
156
161
  request,
162
+ isolatedRequest: isolated.get,
157
163
  config,
158
164
  ctx: apiContext,
159
165
  testInfo,
@@ -161,6 +167,7 @@ export const test = base.extend({
161
167
  stepIndex: () => $bddContext.stepIndex,
162
168
  har: apiHarForScenario({ $tags, config, harMode }),
163
169
  }));
170
+ await isolated.dispose();
164
171
  },
165
172
  data: async ({ config, scenario, apiContext }, use, testInfo) => {
166
173
  const provider = new CompositeDataProvider(config, { seed: scenario.fingerprint });
@@ -46,11 +46,22 @@ export declare class Healer {
46
46
  select(primary: Locator, ctx: HealContext, value: string): Promise<void>;
47
47
  expectVisible(primary: Locator, ctx: HealContext): Promise<void>;
48
48
  expectText(primary: Locator, ctx: HealContext, text: string | RegExp): Promise<void>;
49
+ /**
50
+ * The first of `locators`, in preference order, that matches anything. Waits up to
51
+ * primaryTimeoutMs for the LAST one to attach — callers list the loosest locator last, a
52
+ * superset of the others — so an element that renders a moment late is still judged by the
53
+ * most precise locator. When nothing matches, the most precise one is returned for resolve()
54
+ * to wait on and heal from.
55
+ */
56
+ prefer(...locators: [Locator, ...Locator[]]): Promise<Locator>;
49
57
  /**
50
58
  * Wait briefly for the primary locator; on failure probe all candidates in parallel,
51
- * score them and pick the best above minScore. Records a HealEvent either way.
59
+ * score them and pick the best above minScore. A primary that is visible but matches several
60
+ * elements is healed too, for actions that have a role to prefer. Records a HealEvent whenever
61
+ * it heals.
52
62
  */
53
63
  resolve(primary: Locator, ctx: HealContext, action: HealAction): Promise<Locator>;
64
+ private heal;
54
65
  private probe;
55
66
  private record;
56
67
  }