@saywright/vite 0.1.0 → 0.2.0

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/dist/bridge.js CHANGED
@@ -1,9 +1,326 @@
1
- //#region src/protocol.ts
1
+ //#region src/redact.ts
2
+ /**
3
+ * What is never recorded: passwords, one-time codes, keys and tokens, card and bank numbers. The bridge's two
4
+ * recorders (test sessions, and Write test), the studio (for what an older plugin still sends), the tests it writes
5
+ * and what it sends an AI all go by these rules, so a secret is caught the same way wherever it would show.
6
+ *
7
+ * A field is secret by what it is (a password field, a field someone marked private) or by what it is called (its
8
+ * name, id, autocomplete, label or placeholder: “Password”, “cc-number”, “API key”, “رمز التحقق”). A value is secret
9
+ * by its look alone (a card number, a key or a token), whatever its field is called. A test types a secret from the
10
+ * environment (SAYWRIGHT_SECRET_PASSWORD), never from its own file.
11
+ */
12
+ /** Shown in place of a secret. */
13
+ var MASK = "•••";
14
+ /** A value as it may be shown: a dot for each character, at most twelve, so its length is a hint and nothing more. */
15
+ function dots(value) {
16
+ return "•".repeat(Math.min(value.length, 12));
17
+ }
18
+ /** A name, id or label as plain words: “cardNumber”, “card_number”, “cc-number” → “card number”, “cc number”. */
19
+ function words$1(text) {
20
+ return text.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/[^\p{L}\p{N}]+/gu, " ").trim().toLowerCase();
21
+ }
22
+ /** Words that make a field secret, whatever else it is called. */
23
+ var SECRET_WORDS = new RegExp("\\b(?:" + [
24
+ "pass(?:word|wd|code|phrase|key)s?",
25
+ "pwd",
26
+ "pw",
27
+ "secrets?",
28
+ "tokens?",
29
+ "api ?keys?",
30
+ "access ?keys?",
31
+ "secret ?keys?",
32
+ "private ?keys?",
33
+ "client ?secrets?",
34
+ "licen[cs]e ?keys?",
35
+ "product ?keys?",
36
+ "activation ?(?:keys?|codes?)",
37
+ "recovery ?(?:keys?|codes?)",
38
+ "backup ?codes?",
39
+ "auth(?:entication|orization)? ?codes?",
40
+ "otp",
41
+ "totp",
42
+ "one ?time ?(?:codes?|passwords?|passcodes?|pins?)",
43
+ "(?:verification|security|confirmation|sms|2fa|mfa) ?codes?",
44
+ "2fa",
45
+ "mfa",
46
+ "cvv2?",
47
+ "cvc2?",
48
+ "csc",
49
+ "card ?(?:numbers?|num|no|codes?|verification|holder|security|expiry|expiration|exp|cvc|cvv)",
50
+ "name ?on ?card",
51
+ "credit ?cards?",
52
+ "debit ?cards?",
53
+ "cc ?(?:number|num|no|exp|expiry|csc|cvc|cvv|name|given|family|type)",
54
+ "iban",
55
+ "account ?(?:numbers?|num|no)",
56
+ "routing ?(?:numbers?|num|no)",
57
+ "sort ?codes?",
58
+ "ssn",
59
+ "social ?security",
60
+ "tax ?id",
61
+ "national ?id",
62
+ "passports?",
63
+ "pins?(?: ?codes?)?",
64
+ "(?:recovery|seed|secret|backup|mnemonic) ?(?:phrases?|words?)",
65
+ "mnemonics?",
66
+ "(?:security|secret) ?(?:questions?|answers?)",
67
+ "memorable ?(?:words?|information|info)",
68
+ "mother s maiden name"
69
+ ].join("|") + ")\\b");
70
+ /** The same in Arabic (password, secret code, verification code, card number, account number). */
71
+ var SECRET_WORDS_AR = /كلمة (?:ال)?(?:مرور|سر)|(?:ال)?رمز (?:ال)?(?:سري|تحقق|التحقق|أمان|الأمان|المرور)|رقم (?:ال)?(?:بطاقة|حساب|سري)|آيبان/;
72
+ /** Whether a field described so (its type, name, id, autocomplete, label and placeholder, in one text) holds a secret. */
73
+ function isSecretHint(hint) {
74
+ return SECRET_WORDS.test(words$1(hint)) || SECRET_WORDS_AR.test(hint);
75
+ }
76
+ /** Words a field's label adds that don't tell one secret from another. */
77
+ var FILLER = /* @__PURE__ */ new Set([
78
+ "enter",
79
+ "your",
80
+ "the",
81
+ "a",
82
+ "an",
83
+ "please",
84
+ "type",
85
+ "confirm",
86
+ "confirmation",
87
+ "repeat",
88
+ "again",
89
+ "re",
90
+ "retype",
91
+ "field",
92
+ "here",
93
+ "of",
94
+ "required",
95
+ "optional"
96
+ ]);
97
+ /** A word before a secret that does tell it from another: a new password isn't the current one. */
98
+ var QUALIFIER = /* @__PURE__ */ new Set([
99
+ "new",
100
+ "old",
101
+ "current",
102
+ "previous",
103
+ "admin",
104
+ "master",
105
+ "root"
106
+ ]);
107
+ /**
108
+ * What a secret is called in a test, from its field's label (or name): “Enter your password” → PASSWORD, “Card
109
+ * number” → CARD_NUMBER, “New password” → NEW_PASSWORD, “Password (8 characters or more)” → PASSWORD; “Confirm
110
+ * password” is PASSWORD too, so one value fills both.
111
+ */
112
+ function secretName(label) {
113
+ const w = words$1(label);
114
+ const m = SECRET_WORDS.exec(w);
115
+ let name;
116
+ if (m) {
117
+ const before = w.slice(0, m.index).trim().split(" ").pop() ?? "";
118
+ name = [...QUALIFIER.has(before) ? [before] : [], ...m[0].split(" ")];
119
+ } else name = w.split(" ").filter((x) => x && !FILLER.has(x) && /^[a-z0-9]+$/.test(x)).slice(0, 3);
120
+ if (name.length) return name.join("_").toUpperCase().slice(0, 40);
121
+ if (/كلمة (?:ال)?(?:مرور|سر)/.test(label)) return "PASSWORD";
122
+ if (/تحقق|التحقق/.test(label)) return "VERIFICATION_CODE";
123
+ if (/بطاقة/.test(label)) return "CARD_NUMBER";
124
+ return "SECRET";
125
+ }
126
+ /** What may be a card number: digits with spaces or dashes between them (isCard decides). */
127
+ var CARD = /\b\d(?:[ -]?\d){12,22}\b/g;
128
+ /** Keys and tokens by their shape. */
129
+ var TOKENS = [
130
+ /\beyJ[\w-]{6,}\.eyJ[\w-]{6,}\.[\w-]{6,}/g,
131
+ /\b(?:sk|rk)-[\w-]{16,}/g,
132
+ /\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{10,}/g,
133
+ /\bgh[pousr]_[A-Za-z0-9]{20,}/g,
134
+ /\bgithub_pat_[A-Za-z0-9_]{20,}/g,
135
+ /\bglpat-[\w-]{16,}/g,
136
+ /\bxox[abprs]-[A-Za-z0-9-]{10,}/g,
137
+ /\bnpm_[A-Za-z0-9]{30,}/g,
138
+ /\bAKIA[0-9A-Z]{16}\b/g,
139
+ /\bAIza[0-9A-Za-z_-]{35}/g,
140
+ /\bwhsec_[A-Za-z0-9]{16,}/g,
141
+ /\bSG\.[\w-]{16,}\.[\w-]{16,}/g,
142
+ /(?<=\b(?:aws_?secret_?access_?key|secret_?access_?key|client_?secret|api_?secret|private_?key|access_?token|refresh_?token|auth_?token|password|passwd)["']?\s*[:=]\s*["']?)[^\s"',;}{]{8,}/gi,
143
+ /\b[a-z]{2,8}_(?:(?:live|test)_)?(?=[A-Za-z0-9]{20,}\b)(?=[A-Za-z0-9]*\d)(?=[A-Za-z0-9]*[a-z])(?=[A-Za-z0-9]*[A-Z])[A-Za-z0-9]{20,}\b/g,
144
+ /\bBearer\s+[A-Za-z0-9._~+/-]{12,}=*/g,
145
+ /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z ]*PRIVATE KEY-----|$)/g
146
+ ];
147
+ function luhn(digits) {
148
+ let sum = 0;
149
+ for (let i = 0; i < digits.length; i++) {
150
+ let d = Number(digits[digits.length - 1 - i]);
151
+ if (i % 2) d = d * 2 > 9 ? d * 2 - 9 : d * 2;
152
+ sum += d;
153
+ }
154
+ return sum % 10 === 0;
155
+ }
156
+ /**
157
+ * Whether digits are written as a card number is: 13 to 19 of them, starting with 2 to 6 (as cards of every network
158
+ * do), passing the Luhn check, and either unbroken or in groups of 4 to 6 (the last one shorter) with one kind of
159
+ * separator. Not a date with a number either side of it, an order row or a timestamp.
160
+ */
161
+ function isCard(text) {
162
+ const d = text.replace(/\D/g, "");
163
+ if (d.length < 13 || d.length > 19 || !/^[2-6]/.test(d) || !luhn(d)) return false;
164
+ if (/^\d+$/.test(text)) return true;
165
+ if (text.includes(" ") && text.includes("-")) return false;
166
+ const groups = text.split(/[ -]/);
167
+ return groups.every((g) => /^\d+$/.test(g)) && groups.slice(0, -1).every((g) => g.length >= 4 && g.length <= 6) && groups[groups.length - 1].length <= 6;
168
+ }
169
+ /** A card number in `run` (digits with spaces or dashes), with what follows it kept: the longest first part that is one. */
170
+ function maskCard(run) {
171
+ const parts = run.split(/(?<=\d)(?=[ -])/);
172
+ for (let k = parts.length; k > 0; k--) {
173
+ const head = parts.slice(0, k).join("");
174
+ if (isCard(head)) return `•••• ${head.replace(/\D/g, "").slice(-4)}${parts.slice(k).join("")}`;
175
+ }
176
+ return run;
177
+ }
178
+ /** Text with every card number (kept: its last four digits) and every key- or token-shaped string in it masked. */
179
+ function maskSecrets(text) {
180
+ if (!text) return text;
181
+ let out = text.replace(CARD, maskCard);
182
+ out = out.replace(/\b([a-z][a-z0-9+.-]*:\/\/[^\s:@/]+:)([^\s@/]+)@/gi, (_m, head) => `${head}${MASK}@`);
183
+ for (const re of TOKENS) out = out.replace(re, (m) => m.startsWith("Bearer") ? `Bearer ${MASK}` : MASK);
184
+ return out;
185
+ }
186
+ /**
187
+ * Whether a typed value is a secret by its look alone: a card number, a key or a token, or a long random string (of
188
+ * letters in both cases and digits, as keys are; or 32 hexadecimal digits or more). A slug, a sentence, an email
189
+ * address or a coupon code is not.
190
+ */
191
+ function looksSecret(value) {
192
+ const v = value.trim();
193
+ if (v.length < 12) return false;
194
+ if (maskSecrets(v) !== v) return true;
195
+ if (/^[a-f0-9]{32,}$/i.test(v) && /\d/.test(v) && /[a-f]/i.test(v)) return true;
196
+ return /^[A-Za-z0-9+/_=-]{24,}$/.test(v) && /\d/.test(v) && /[a-z]/.test(v) && /[A-Z]/.test(v);
197
+ }
198
+ /** Query and hash parameters whose value is a secret by their name alone. */
199
+ var SECRET_PARAM = /^(?:access_?token|refresh_?token|id_?token|auth_?token|token|auth|authorization|api_?key|apikey|key|secret|client_?secret|password|passwd|pwd|pass|otp|code_?verifier|jwt|session|session_?id|sessionid|sid|signature|sig|x-amz-signature|x-amz-credential|x-amz-security-token|x-goog-signature|x-goog-credential)$/i;
200
+ /** Addresses of sign-in steps, where `code`, `state` and `nonce` carry what lets someone in. */
201
+ var AUTH_PATH = /(?:^|\/)(?:callback|oauth2?|auth|authorize|sso|login|signin|sign-in|verify|magic|reset|confirm)(?:[/?#.]|$)/i;
202
+ /** Whether a page's path is a sign-in step's (/login, /verify, /auth/callback…): a code asked for there lets someone in. */
203
+ function isAuthPath(pathname) {
204
+ return AUTH_PATH.test(pathname);
205
+ }
206
+ /** Codes that are not secrets: a test needs them as typed (SAVE20, a postal code, a colour). */
207
+ var PLAIN_CODE = /\b(?:coupon|promo|promotion|discount|voucher|gift|referral|invite|invitation|postal|post|zip|area|country|dialling|dialing|product|sku|item|colou?r|tracking|order|booking|reference|ref|course|class|group|room|seat|ticket|event|campaign|source|language|currency|qr|bar|barcode|html|css|snippet|embed|promotional)\b/;
208
+ /**
209
+ * Whether a field described so asks for a code that lets someone in, by its look: called a code or a PIN (“Code”,
210
+ * “Enter the 6-digit code we emailed you”), not a coupon or a postal code, and short and numeric (its input mode,
211
+ * type, pattern or length), or asked for on a sign-in step's page.
212
+ */
213
+ function isCodeField(hint, field, pathname = "") {
214
+ const w = words$1(hint);
215
+ if (!/\b(?:code|codes|pin|passcode)\b/.test(w) || PLAIN_CODE.test(w)) return false;
216
+ return field.numeric || field.shortMax || isAuthPath(pathname);
217
+ }
218
+ function decoded(s) {
219
+ try {
220
+ return decodeURIComponent(s.replace(/\+/g, " "));
221
+ } catch {
222
+ return s;
223
+ }
224
+ }
225
+ /** `a=1&b=2` with the values of secret parameters masked, and key- or token-shaped values too. */
226
+ function maskParams(params, authPath) {
227
+ return params.split("&").map((pair) => {
228
+ const eq = pair.indexOf("=");
229
+ if (eq < 0) return maskSecrets(pair);
230
+ const name = decoded(pair.slice(0, eq));
231
+ const value = pair.slice(eq + 1);
232
+ if (!value) return pair;
233
+ const secret = SECRET_PARAM.test(name) || isSecretHint(name) || authPath && /^(?:code|state|nonce)$/i.test(name) || looksSecret(decoded(value));
234
+ return `${pair.slice(0, eq)}=${secret ? MASK : maskSecrets(value)}`;
235
+ }).join("&");
236
+ }
237
+ /**
238
+ * An address with the secrets in it masked: the values of parameters named like a token, key, password or session
239
+ * (in its query, and in its hash: `#access_token=…`), sign-in codes, and key- or token-shaped parts of its path.
240
+ * Works on whole and relative addresses, and never throws.
241
+ */
242
+ function redactUrl(url) {
243
+ if (!url) return url;
244
+ const hashAt = url.indexOf("#");
245
+ const base = hashAt < 0 ? url : url.slice(0, hashAt);
246
+ const hash = hashAt < 0 ? null : url.slice(hashAt + 1);
247
+ const q = base.indexOf("?");
248
+ const path = q < 0 ? base : base.slice(0, q);
249
+ const authPath = AUTH_PATH.test(path);
250
+ let out = maskSecrets(path).replace(/(?<=\/)[A-Za-z0-9_-]{24,}(?=\/|$)/g, (part) => /\d/.test(part) && /[a-z]/.test(part) && /[A-Z]/.test(part) ? MASK : part);
251
+ if (q >= 0) out += `?${maskParams(base.slice(q + 1), authPath)}`;
252
+ if (hash !== null) {
253
+ const hq = hash.indexOf("?");
254
+ if (hq >= 0) out += `#${maskSecrets(hash.slice(0, hq))}?${maskParams(hash.slice(hq + 1), authPath || AUTH_PATH.test(hash.slice(0, hq)))}`;
255
+ else out += `#${hash.includes("=") ? maskParams(hash, authPath) : maskSecrets(hash)}`;
256
+ }
257
+ return out;
258
+ }
259
+ /** A recorded element's words, masked: text it showed may hold a card number or a key. */
260
+ function scrubTarget(el) {
261
+ if (!el || typeof el !== "object") return;
262
+ const t = el;
263
+ if (typeof t.label === "string") t.label = maskSecrets(t.label);
264
+ if (typeof t.text === "string") t.text = maskSecrets(t.text);
265
+ if (typeof t.description === "string") t.description = maskSecrets(t.description);
266
+ }
2
267
  /**
3
- * Message protocol between the Saywright studio (parent window) and the
4
- * bridge running inside the target app (iframe). Both sides check `ns` and the
5
- * message source before trusting anything.
268
+ * A session event as it may be kept: addresses with their secrets masked (see redactUrl), card numbers and keys
269
+ * masked in every text it carries (errors, console lines, response bodies, what the screen said), and a typed value
270
+ * that is a secret (by its field's name, or its look) as dots. The bridge records events so; the studio scrubs
271
+ * what arrives again, for events an older plugin recorded. Changes the event in place, and returns it.
6
272
  */
273
+ function scrubEvent(event) {
274
+ const e = event;
275
+ const url = (k) => {
276
+ if (typeof e[k] === "string") e[k] = redactUrl(e[k]);
277
+ };
278
+ const text = (k) => {
279
+ if (typeof e[k] === "string") e[k] = maskSecrets(e[k]);
280
+ };
281
+ scrubTarget(e.el);
282
+ switch (e.type) {
283
+ case "page":
284
+ url("url");
285
+ url("referrer");
286
+ break;
287
+ case "nav":
288
+ url("url");
289
+ break;
290
+ case "net":
291
+ url("url");
292
+ text("body");
293
+ text("error");
294
+ break;
295
+ case "error":
296
+ case "console":
297
+ text("message");
298
+ text("text");
299
+ text("stack");
300
+ break;
301
+ case "dom":
302
+ url("url");
303
+ text("text");
304
+ if (Array.isArray(e.headings)) e.headings = e.headings.map((h) => typeof h === "string" ? maskSecrets(h) : h);
305
+ break;
306
+ case "input": {
307
+ const target = e.el ?? {};
308
+ const value = typeof e.value === "string" ? e.value : "";
309
+ if (!e.masked && (isSecretHint(`${target.label ?? ""} ${target.name ?? ""}`) || looksSecret(value))) {
310
+ e.value = dots(value);
311
+ e.masked = true;
312
+ e.secret ??= secretName(target.label || target.name || "");
313
+ } else if (!e.masked) text("value");
314
+ break;
315
+ }
316
+ case "change":
317
+ text("value");
318
+ if (Array.isArray(e.options)) e.options = e.options.map((o) => typeof o === "string" ? maskSecrets(o) : o);
319
+ }
320
+ return event;
321
+ }
322
+ //#endregion
323
+ //#region src/protocol.ts
7
324
  var NS = "saywright";
8
325
  /** Which side has to be updated for a studio and an app's bridge to work together: the app's plugin, the studio, or neither. */
9
326
  function outdated(studio, bridge) {
@@ -11,7 +328,14 @@ function outdated(studio, bridge) {
11
328
  if (studio.v < (bridge.oldest ?? bridge.v)) return "studio";
12
329
  return null;
13
330
  }
14
- var STUDIO_URL = `http://localhost:7357`;
331
+ /** Where the studio runs unless told otherwise. Not 5173: that is Vite's own default, where your app likely runs. */
332
+ var STUDIO_PORT = 7357;
333
+ /**
334
+ * Through the studio's proxy, a page's XMLHttpRequest to another port of this computer says with this header that it
335
+ * was sent with its credentials (withCredentials, set after open): the proxy then sends the site's cookies with it.
336
+ */
337
+ var PROXY_CREDENTIALS_HEADER = "x-saywright-credentials";
338
+ var STUDIO_URL = `http://localhost:${STUDIO_PORT}`;
15
339
  /** Attribute names injected by the Vite plugin at compile time. */
16
340
  var SRC_ATTR = "data-ds-src";
17
341
  function isStudioMessage(data) {
@@ -451,13 +775,13 @@ function collapse(s, max = 80) {
451
775
  }
452
776
  /**
453
777
  * Text as a person reads it on screen, line by line: blank lines dropped, spaces collapsed, long lines cut, whole
454
- * lines up to `max` characters, and card numbers masked to their last four digits.
778
+ * lines up to `max` characters, card numbers masked to their last four digits, and keys and tokens masked.
455
779
  */
456
780
  function screenLines(text, max, line = 200) {
457
781
  const out = [];
458
782
  let size = 0;
459
783
  for (const raw of text.split("\n")) {
460
- const l = collapse(maskCards(raw), line);
784
+ const l = collapse(maskSecrets(raw), line);
461
785
  if (!l) continue;
462
786
  if (size + l.length + 1 > max) break;
463
787
  out.push(l);
@@ -465,22 +789,6 @@ function screenLines(text, max, line = 200) {
465
789
  }
466
790
  return out.join("\n");
467
791
  }
468
- /** A card number (13 to 19 digits that pass the Luhn check, spaces or dashes allowed) shown as •••• and its last four. */
469
- function maskCards(text) {
470
- return text.replace(/\b\d(?:[ -]?\d){12,18}\b/g, (m) => {
471
- const digits = m.replace(/\D/g, "");
472
- return luhn(digits) ? `•••• ${digits.slice(-4)}` : m;
473
- });
474
- }
475
- function luhn(digits) {
476
- let sum = 0;
477
- for (let i = 0; i < digits.length; i++) {
478
- let d = Number(digits[digits.length - 1 - i]);
479
- if (i % 2) d = d * 2 > 9 ? d * 2 - 9 : d * 2;
480
- sum += d;
481
- }
482
- return sum % 10 === 0;
483
- }
484
792
  /**
485
793
  * What an element is made of, in a few characters: its tag and classes, and a hash of what is inside it
486
794
  * (without Saywright's attributes or ids that change from one load to the next). Two icon buttons written one
@@ -511,11 +819,11 @@ function roleOf(el) {
511
819
  case "a": return el.hasAttribute("href") ? "link" : null;
512
820
  case "input": {
513
821
  const type = (el.type || "text").toLowerCase();
514
- if (type === "hidden") return null;
822
+ if (type === "hidden" || type === "password") return null;
515
823
  return INPUT_ROLES[type] ?? "textbox";
516
824
  }
517
825
  case "textarea": return "textbox";
518
- case "select": return "combobox";
826
+ case "select": return el.multiple || el.size > 1 ? "listbox" : "combobox";
519
827
  case "img": return el.getAttribute("alt") ? "img" : null;
520
828
  case "nav": return "navigation";
521
829
  case "main": return "main";
@@ -538,42 +846,204 @@ function textOfIds(ids) {
538
846
  function isField(el) {
539
847
  return el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement || el instanceof HTMLSelectElement;
540
848
  }
849
+ /** What people already mark so that session-replay tools never record it (Saywright's own mark first). */
850
+ var PRIVATE = "[data-saywright-secret], [data-private], [data-sensitive], [data-mask], [data-sentry-mask], [data-hj-suppress], [data-ph-no-capture], .rr-mask, .rr-block, .sentry-mask, .fs-mask, .fs-exclude, .hj-suppress, .ph-no-capture";
851
+ /** Password fields seen as such: one that “show password” turned into a text field still holds a password. */
852
+ var passwords = /* @__PURE__ */ new WeakSet();
853
+ /** Words that describe a field: its type, name, id, autocomplete, label, placeholder and test id. */
854
+ function fieldHint(el) {
855
+ return [
856
+ el.type,
857
+ el.getAttribute("name"),
858
+ el.id,
859
+ el.getAttribute("autocomplete"),
860
+ el.getAttribute("aria-label"),
861
+ el.getAttribute("placeholder"),
862
+ el.getAttribute("data-testid"),
863
+ labelText(el)
864
+ ].filter(Boolean).join(" ");
865
+ }
866
+ /**
867
+ * Whether what is typed into this field is a secret, never to be recorded or shown: a password field (also once
868
+ * “show password” has made it a text field), a field marked private (data-saywright-secret, or the marks other
869
+ * replay tools use), one called like a secret (see isSecretHint), or one holding a value that looks like one.
870
+ */
871
+ function isSecretField(el) {
872
+ if (!isField(el) || el instanceof HTMLSelectElement) return false;
873
+ if (el instanceof HTMLInputElement && el.type === "password") {
874
+ passwords.add(el);
875
+ return true;
876
+ }
877
+ if (passwords.has(el) || el.closest("[data-saywright-secret], [data-private], [data-sensitive], [data-mask], [data-sentry-mask], [data-hj-suppress], [data-ph-no-capture], .rr-mask, .rr-block, .sentry-mask, .fs-mask, .fs-exclude, .hj-suppress, .ph-no-capture")) return true;
878
+ if (el instanceof HTMLInputElement && [
879
+ "checkbox",
880
+ "radio",
881
+ "file",
882
+ "range",
883
+ "color",
884
+ "submit",
885
+ "button",
886
+ "reset",
887
+ "image",
888
+ "hidden"
889
+ ].includes(el.type)) return false;
890
+ const hint = fieldHint(el);
891
+ return isSecretHint(hint) || looksSecret(el.value) || codeField(el, hint);
892
+ }
893
+ /** A short numeric code asked for to sign in (“Code”, “Enter the 6-digit code we emailed you”), though nothing says one-time. */
894
+ function codeField(el, hint) {
895
+ if (!(el instanceof HTMLInputElement)) return false;
896
+ const mode = (el.getAttribute("inputmode") ?? "").toLowerCase();
897
+ return isCodeField(hint, {
898
+ numeric: mode === "numeric" || mode === "decimal" || el.type === "tel" || el.type === "number" || /\\d|\[0-9/.test(el.pattern),
899
+ shortMax: el.maxLength >= 4 && el.maxLength <= 8
900
+ }, location.pathname);
901
+ }
902
+ /** What a test calls a secret field's value: PASSWORD, CARD_NUMBER… (from its label, else its name). */
903
+ function secretNameOf(el) {
904
+ const name = ownSecretName(el);
905
+ if (name === "PASSWORD" && /\b(?:confirm|repeat|retype|re-?enter|again|verify)\b/i.test(fieldHint(el))) {
906
+ const form = el.closest("form") ?? el.parentElement?.closest("section, fieldset, div");
907
+ for (const other of Array.from(form?.querySelectorAll("input") ?? [])) {
908
+ if (other === el || !isSecretField(other)) continue;
909
+ const theirs = ownSecretName(other);
910
+ if (theirs !== "PASSWORD" && /PASSWORD$/.test(theirs)) return theirs;
911
+ }
912
+ }
913
+ return name;
914
+ }
915
+ function ownSecretName(el) {
916
+ if (el instanceof HTMLInputElement && el.type === "password" && !isSecretHint(labelText(el) || el.getAttribute("placeholder") || el.getAttribute("name") || "")) return "PASSWORD";
917
+ if (isField(el) && !(el instanceof HTMLSelectElement) && !isSecretHint(fieldHint(el)) && codeField(el, fieldHint(el))) return "ONE_TIME_CODE";
918
+ return secretName(labelText(el) || el.getAttribute("aria-label") || el.getAttribute("placeholder") || el.getAttribute("autocomplete") || el.getAttribute("name") || el.id || "");
919
+ }
920
+ /** What a field holds, as it may be shown or sent anywhere: a secret's value as dots, other values with any card number or key in them masked. */
921
+ function fieldValue(el) {
922
+ return isSecretField(el) ? dots(el.value) : maskSecrets(el.value);
923
+ }
924
+ /**
925
+ * A label's own words, as Playwright and screen readers name a field by it: without the fields inside it (a list's
926
+ * options, a text area's text), so `<label>Plan <select>…</select></label>` is “Plan”, not “Plan BasicPlus”.
927
+ */
928
+ function labelWords(label) {
929
+ let out = "";
930
+ const walk = (n) => {
931
+ if (n.nodeType === Node.TEXT_NODE) out += n.textContent ?? "";
932
+ else if (n instanceof Element && !isField(n) && !(n instanceof HTMLButtonElement)) for (const c of Array.from(n.childNodes)) walk(c);
933
+ else if (n instanceof HTMLButtonElement) out += n.textContent ?? "";
934
+ };
935
+ walk(label);
936
+ return out;
937
+ }
938
+ /** Roles of a control that is checked or not, as Playwright's toBeChecked reads it. */
939
+ var TOGGLE_ROLES = /* @__PURE__ */ new Set([
940
+ "checkbox",
941
+ "radio",
942
+ "switch",
943
+ "menuitemcheckbox",
944
+ "menuitemradio"
945
+ ]);
946
+ var isToggle$1 = (el) => el instanceof HTMLInputElement && (el.type === "checkbox" || el.type === "radio") || TOGGLE_ROLES.has(el.getAttribute("role") ?? "");
947
+ /**
948
+ * The control whose state something pointed at stands for: an option's list, a label's checkbox, the checkbox or
949
+ * switch it is part of; else itself.
950
+ */
951
+ function controlOf(el) {
952
+ if (el instanceof HTMLOptionElement || el instanceof HTMLOptGroupElement) return el.closest("select") ?? el;
953
+ if (isToggle$1(el) || el instanceof HTMLSelectElement) return el;
954
+ const label = el.closest("label");
955
+ const labelled = label?.control ?? label?.querySelector("input, select");
956
+ if (labelled && (isToggle$1(labelled) || labelled instanceof HTMLSelectElement)) return labelled;
957
+ return el.closest("[role=\"checkbox\"], [role=\"radio\"], [role=\"switch\"], [role=\"menuitemcheckbox\"], [role=\"menuitemradio\"]") ?? el;
958
+ }
959
+ /** A state check's facts: whether the control is checked; for an option of a list, what it says and what is chosen now. */
960
+ function stateOf(pointed, control) {
961
+ if (control instanceof HTMLSelectElement) {
962
+ const option = pointed instanceof HTMLOptionElement ? collapse(pointed.label || pointed.text, 80) : void 0;
963
+ return {
964
+ ...option ? { option } : {},
965
+ chosen: Array.from(control.selectedOptions, (o) => collapse(o.label || o.text, 80))
966
+ };
967
+ }
968
+ if (control instanceof HTMLInputElement && isToggle$1(control)) return { checked: control.checked };
969
+ if (isToggle$1(control)) return { checked: control.getAttribute("aria-checked") === "true" };
970
+ return {};
971
+ }
541
972
  /** Associated <label> text for a form field. */
542
973
  function labelText(el) {
543
974
  if (!isField(el)) return "";
544
975
  const parts = [];
545
- if (el.id) for (const l of Array.from(document.querySelectorAll(`label[for="${CSS.escape(el.id)}"]`))) parts.push(l.textContent ?? "");
976
+ if (el.id) for (const l of Array.from(document.querySelectorAll(`label[for="${CSS.escape(el.id)}"]`))) parts.push(labelWords(l));
546
977
  const wrapping = el.closest("label");
547
- if (wrapping) parts.push(wrapping.textContent ?? "");
978
+ if (wrapping) parts.push(labelWords(wrapping));
548
979
  return collapse(parts.join(" "), 60);
549
980
  }
550
- /** A practical approximation of the accessible name (enough for stable test locators). */
981
+ var NAME_MAX = 200;
982
+ /**
983
+ * A practical approximation of the accessible name (enough for stable test locators). Whole up to 200 characters:
984
+ * a locator is never written from a cut one (it would find nothing).
985
+ */
551
986
  function accessibleName(el) {
552
987
  const aria = el.getAttribute("aria-label");
553
- if (aria) return collapse(aria, 60);
988
+ if (aria) return collapse(aria, NAME_MAX);
554
989
  const labelledBy = el.getAttribute("aria-labelledby");
555
- if (labelledBy) return collapse(textOfIds(labelledBy), 60);
556
- if (isField(el)) return labelText(el) || collapse(el.getAttribute("title"), 60);
557
- if (el instanceof HTMLImageElement) return collapse(el.alt, 60);
990
+ if (labelledBy) return collapse(textOfIds(labelledBy), NAME_MAX);
991
+ if (isField(el)) return labelText(el) || collapse(el.getAttribute("title"), NAME_MAX);
992
+ if (el instanceof HTMLImageElement) return collapse(el.alt, NAME_MAX);
558
993
  const title = el.getAttribute("title");
559
- return collapse(el.innerText ?? el.textContent, 60) || collapse(title, 60);
994
+ return collapse(el.querySelector("img[alt], [aria-label]") ? contentName(el) : el.innerText ?? el.textContent, NAME_MAX) || collapse(title, NAME_MAX);
995
+ }
996
+ /**
997
+ * An element's name from what is inside it, as Playwright and screen readers read it: text, an image's alt, a part's
998
+ * own label; hidden parts left out, a block on its own words. A link that is only a picture is named by the picture.
999
+ */
1000
+ function contentName(el) {
1001
+ let out = "";
1002
+ const walk = (n) => {
1003
+ if (n.nodeType === Node.TEXT_NODE) {
1004
+ out += n.textContent ?? "";
1005
+ return;
1006
+ }
1007
+ if (!(n instanceof Element) || n.getAttribute("aria-hidden") === "true" || n.hasAttribute("hidden")) return;
1008
+ const display = getComputedStyle(n).display;
1009
+ if (display === "none") return;
1010
+ const pad = display !== "inline" ? " " : "";
1011
+ const own = n.getAttribute("aria-label") || (n instanceof HTMLImageElement ? n.alt : null);
1012
+ if (own !== null && own !== "") out += `${pad}${own}${pad}`;
1013
+ else if (!(n instanceof HTMLImageElement)) {
1014
+ out += pad;
1015
+ for (const c of Array.from(n.childNodes)) walk(c);
1016
+ out += pad;
1017
+ }
1018
+ };
1019
+ for (const c of Array.from(el.childNodes)) walk(c);
1020
+ return out;
560
1021
  }
561
1022
  function visibleText(el) {
562
- if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) return collapse(el.value || el.placeholder, 80);
1023
+ if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) return collapse(isSecretField(el) ? el.placeholder : el.value || el.placeholder, 80);
563
1024
  return collapse(el.innerText ?? el.textContent, 80);
564
1025
  }
565
1026
  function shortLabel(el) {
566
1027
  const aria = el.getAttribute("aria-label");
567
1028
  if (aria) return collapse(aria, 28);
568
- if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) return collapse(labelText(el) || el.placeholder || el.value, 28);
1029
+ if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) return collapse(labelText(el) || el.placeholder || (isSecretField(el) ? "" : el.value), 28);
569
1030
  return collapse(el.innerText ?? el.textContent, 28);
570
1031
  }
571
- /** Index of `el` among nodes rendered by the same JSX site (same data-ds-src). */
1032
+ /** Index of `el` among nodes rendered by the same JSX site (same data-ds-src), and how many there are. */
572
1033
  function srcIndex(el, src) {
573
- if (!src) return 0;
1034
+ if (!src) return {
1035
+ index: 0,
1036
+ same: 1
1037
+ };
574
1038
  const all = document.querySelectorAll(`[${SRC_ATTR}="${CSS.escape(src)}"]`);
575
- for (let i = 0; i < all.length; i++) if (all[i] === el) return i;
576
- return 0;
1039
+ for (let i = 0; i < all.length; i++) if (all[i] === el) return {
1040
+ index: i,
1041
+ same: all.length
1042
+ };
1043
+ return {
1044
+ index: 0,
1045
+ same: all.length
1046
+ };
577
1047
  }
578
1048
  function describe(el) {
579
1049
  const annotated = el.getAttribute(SRC_ATTR);
@@ -595,8 +1065,11 @@ function describe(el) {
595
1065
  text: visibleText(el),
596
1066
  role: roleOf(el),
597
1067
  rect: rectOf(el),
598
- index: srcIndex(el, src)
1068
+ index: 0
599
1069
  };
1070
+ const at = srcIndex(el, annotated);
1071
+ info.index = at.index;
1072
+ if (at.same > 1) info.same = at.same;
600
1073
  if (frames.length) info.frames = frames;
601
1074
  if (!src) info.hint = sourceHint(el);
602
1075
  return info;
@@ -810,14 +1283,47 @@ function scrollAt(x, y, dx, dy) {
810
1283
  function looksVolatile(s) {
811
1284
  return /^[\s$€£¥+\-−–.,:%0-9()]*$/.test(s) || /\d/.test(s) && s.length <= 12;
812
1285
  }
1286
+ var squash = (s) => (s ?? "").replace(/\s+/g, "").toLowerCase();
1287
+ /** A number as a page shows it: 12, 1,234.50, 3.5. */
1288
+ var NUMBER = /\d[\d.,]*/g;
1289
+ var escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&");
1290
+ /**
1291
+ * A name or a text with numbers in it ("Pay $91.57 and subscribe", "Like this portfolio (1)") as a pattern with the
1292
+ * numbers left open, so a fix that changes a price or a count still finds it. Null without numbers, or when only
1293
+ * its numbers say what it is.
1294
+ */
1295
+ function numbersOpen(s) {
1296
+ if (!/\d/.test(s) || s.length > 60 || s.endsWith("…")) return null;
1297
+ const parts = s.split(NUMBER);
1298
+ if (parts.join("").replace(/[^\p{L}]+/gu, "").length < 3) return null;
1299
+ const source = `^${parts.map(escapeRe).join("\\d[\\d.,]*")}$`;
1300
+ const hint = parts.map(squash).sort((a, b) => b.length - a.length)[0] ?? "";
1301
+ return {
1302
+ re: new RegExp(source),
1303
+ code: `/${source}/`,
1304
+ hint
1305
+ };
1306
+ }
1307
+ var always = () => true;
1308
+ /** What the element will show: the expected value in place of the part of its text shaped like it, else that value alone. */
1309
+ function keeper(el, expected) {
1310
+ const now = visibleText(el);
1311
+ const shape = expected.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/\d+/g, "\\d+");
1312
+ const part = /\d/.test(expected) && shape !== expected ? new RegExp(shape).exec(now)?.[0] : void 0;
1313
+ const then = now.toLowerCase().includes(expected.toLowerCase()) ? now : part ? now.replace(part, () => expected) : expected;
1314
+ return (w) => typeof w === "string" ? then.toLowerCase().includes(w.toLowerCase()) : w.re.test(then);
1315
+ }
1316
+ /** Whether its name is its own text (a button's words), not given apart from it (a label, aria-label). */
1317
+ var namedByText = (el) => !el.hasAttribute("aria-label") && !el.hasAttribute("aria-labelledby") && !isField(el) && !(el instanceof HTMLImageElement);
813
1318
  var ROLE_SELECTORS = {
814
1319
  button: "button, [role=\"button\"], input[type=\"button\"], input[type=\"submit\"], input[type=\"reset\"]",
815
1320
  link: "a[href], [role=\"link\"]",
816
1321
  heading: "h1, h2, h3, h4, h5, h6, [role=\"heading\"]",
817
- textbox: "input:not([type]), input[type=\"text\"], input[type=\"email\"], input[type=\"url\"], input[type=\"tel\"], input[type=\"password\"], textarea, [role=\"textbox\"]",
1322
+ textbox: "input:not([type]), input[type=\"text\"], input[type=\"email\"], input[type=\"url\"], input[type=\"tel\"], textarea, [role=\"textbox\"]",
818
1323
  checkbox: "input[type=\"checkbox\"], [role=\"checkbox\"]",
819
1324
  radio: "input[type=\"radio\"], [role=\"radio\"]",
820
1325
  combobox: "select, [role=\"combobox\"]",
1326
+ listbox: "select, [role=\"listbox\"]",
821
1327
  tab: "[role=\"tab\"]",
822
1328
  menuitem: "[role=\"menuitem\"]",
823
1329
  article: "article, [role=\"article\"]",
@@ -850,20 +1356,25 @@ function roleFor(el) {
850
1356
  default: return null;
851
1357
  }
852
1358
  }
853
- var squash = (s) => (s ?? "").replace(/\s+/g, "").toLowerCase();
1359
+ /** Playwright's text matching: case-insensitive and partial (spaces collapsed), or exact. */
1360
+ function sameWords(actual, wanted, exact) {
1361
+ const a = actual.replace(/\s+/g, " ").trim();
1362
+ const w = wanted.replace(/\s+/g, " ").trim();
1363
+ return exact ? a === w : a.toLowerCase().includes(w.toLowerCase());
1364
+ }
854
1365
  /** The elements of this role (with this name, when given) in `scope`, as getByRole matches them. */
855
1366
  function byRole(scope, role, name, exact, limit = Infinity) {
856
1367
  const sel = ROLE_SELECTORS[role] ?? `[role="${role}"]`;
857
- const needle = squash(name);
1368
+ const needle = name === null ? "" : typeof name === "string" ? squash(name) : name.hint;
858
1369
  const out = [];
859
1370
  const all = scope.querySelectorAll(sel);
860
1371
  for (let i = 0; i < all.length && out.length < limit; i++) {
861
1372
  const el = all[i];
862
- if (name !== null && !isField(el) && !(el instanceof HTMLImageElement) && !el.hasAttribute("aria-labelledby") && !squash(el.textContent).includes(needle) && !squash(el.getAttribute("aria-label")).includes(needle) && !squash(el.getAttribute("title")).includes(needle)) continue;
1373
+ if (name !== null && !isField(el) && !(el instanceof HTMLImageElement) && !el.hasAttribute("aria-labelledby") && !squash(el.textContent).includes(needle) && !squash(el.getAttribute("aria-label")).includes(needle) && !squash(el.getAttribute("title")).includes(needle) && !el.querySelector("img[alt], [aria-label]")) continue;
863
1374
  if (roleFor(el) !== role) continue;
864
1375
  if (name !== null) {
865
1376
  const an = accessibleName(el);
866
- if (!(exact ? an === name : an.toLowerCase().includes(name.toLowerCase()))) continue;
1377
+ if (!(typeof name !== "string" ? name.re.test(an) : exact ? an === name : an.toLowerCase().includes(name.toLowerCase()))) continue;
867
1378
  }
868
1379
  out.push(el);
869
1380
  }
@@ -875,7 +1386,7 @@ function byRole(scope, role, name, exact, limit = Infinity) {
875
1386
  */
876
1387
  var counted = /* @__PURE__ */ new Map();
877
1388
  function countByRoleAndName(role, name, exact) {
878
- const key = `${role}\n${exact ? 1 : 0}\n${name}`;
1389
+ const key = `${role}\n${exact ? 1 : 0}\n${typeof name === "string" ? name : name.code}`;
879
1390
  const now = performance.now();
880
1391
  const hit = counted.get(key);
881
1392
  if (hit && now - hit.at < 1e3) return hit.n;
@@ -887,13 +1398,15 @@ function countByRoleAndName(role, name, exact) {
887
1398
  });
888
1399
  return n;
889
1400
  }
890
- /** Innermost elements in `scope` whose own text is exactly `text`. */
1401
+ /** Innermost elements in `scope` whose own text is exactly `text` (or matches the pattern). */
891
1402
  function byText$1(scope, text) {
892
1403
  const out = [];
1404
+ const is = typeof text === "string" ? (t) => t === text : (t) => text.re.test(t);
893
1405
  const all = scope.querySelectorAll("*");
894
1406
  for (let i = 0; i < all.length; i++) {
895
1407
  const el = all[i];
896
- if (collapse(el.innerText ?? "", 200) === text && !Array.from(el.children).some((c) => collapse(c.innerText ?? "", 200) === text)) out.push(el);
1408
+ if (typeof text !== "string" && !squash(el.textContent).includes(text.hint)) continue;
1409
+ if (is(collapse(el.innerText ?? "", 200)) && !Array.from(el.children).some((c) => is(collapse(c.innerText ?? "", 200)))) out.push(el);
897
1410
  }
898
1411
  return out;
899
1412
  }
@@ -906,6 +1419,7 @@ function describeRole(role, name) {
906
1419
  checkbox: "checkbox",
907
1420
  radio: "option",
908
1421
  combobox: "menu",
1422
+ listbox: "list",
909
1423
  tab: "tab"
910
1424
  }[role] ?? role}`;
911
1425
  }
@@ -914,7 +1428,22 @@ function namingClass(el) {
914
1428
  for (const c of Array.from(el.classList)) if (/^[a-z][a-z-]{2,30}$/i.test(c) && !/--|^(?:flex|grid|block|inline|hidden|relative|absolute|fixed|sticky|static|container|row|col|clearfix|active|selected|open|disabled|dark|light|sr-only)$|^(?:p|m|w|h|text|bg|border|rounded|shadow|gap|space|items|justify|self|place|overflow|opacity|font|leading|tracking|min|max|ring|transition|duration|ease|cursor|select|pointer|z|top|left|right|bottom|inset|truncate|whitespace|break|shrink|grow|basis|order)-/.test(c)) return c;
915
1429
  return null;
916
1430
  }
917
- function inners(el) {
1431
+ /** A tag as a person names what it holds. */
1432
+ var TAG_WORDS = {
1433
+ td: "cell",
1434
+ th: "heading",
1435
+ dd: "value",
1436
+ li: "item",
1437
+ span: "text",
1438
+ p: "text",
1439
+ strong: "text",
1440
+ b: "text",
1441
+ div: "text",
1442
+ output: "value",
1443
+ time: "time",
1444
+ img: "image"
1445
+ };
1446
+ function inners(el, keep) {
918
1447
  const out = [];
919
1448
  const testId = el.getAttribute("data-testid");
920
1449
  if (testId) out.push({
@@ -924,17 +1453,26 @@ function inners(el) {
924
1453
  });
925
1454
  const role = roleFor(el);
926
1455
  const name = named(role) ? accessibleName(el) : "";
927
- if (role && name && !looksVolatile(name) && name.length <= 60) {
928
- out.push({
929
- code: `getByRole(${jsString(role)}, { name: ${jsString(name)} })`,
930
- words: `“${name}”`,
931
- count: (s) => byRole(s, role, name, false, 2).length
932
- });
933
- out.push({
934
- code: `getByRole(${jsString(role)}, { name: ${jsString(name)}, exact: true })`,
1456
+ const byName = !namedByText(el) || keep === always;
1457
+ if (role && name && name.length <= 60) {
1458
+ const open = numbersOpen(name);
1459
+ if (open && (byName || keep(open))) out.push({
1460
+ code: `getByRole(${jsString(role)}, { name: ${open.code} })`,
935
1461
  words: `“${name}”`,
936
- count: (s) => byRole(s, role, name, true, 2).length
1462
+ count: (s) => byRole(s, role, open, false, 2).length
937
1463
  });
1464
+ if (!looksVolatile(name) && (byName || keep(name))) {
1465
+ out.push({
1466
+ code: `getByRole(${jsString(role)}, { name: ${jsString(name)} })`,
1467
+ words: `“${name}”`,
1468
+ count: (s) => byRole(s, role, name, false, 2).length
1469
+ });
1470
+ out.push({
1471
+ code: `getByRole(${jsString(role)}, { name: ${jsString(name)}, exact: true })`,
1472
+ words: `“${name}”`,
1473
+ count: (s) => byRole(s, role, name, true, 2).length
1474
+ });
1475
+ }
938
1476
  }
939
1477
  if (isField(el)) {
940
1478
  const label = labelText(el);
@@ -951,11 +1489,19 @@ function inners(el) {
951
1489
  });
952
1490
  }
953
1491
  const text = visibleText(el);
954
- if (!isField(el) && text && !looksVolatile(text) && text.length <= 40) out.push({
955
- code: `getByText(${jsString(text)}, { exact: true })`,
956
- words: `“${text}”`,
957
- count: (s) => byText$1(s, text).length
958
- });
1492
+ if (!isField(el) && text && text.length <= 40) {
1493
+ const open = numbersOpen(text);
1494
+ if (open && keep(open)) out.push({
1495
+ code: `getByText(${open.code})`,
1496
+ words: `“${text}”`,
1497
+ count: (s) => byText$1(s, open).length
1498
+ });
1499
+ if (!looksVolatile(text) && keep(text)) out.push({
1500
+ code: `getByText(${jsString(text)}, { exact: true })`,
1501
+ words: `“${text}”`,
1502
+ count: (s) => byText$1(s, text).length
1503
+ });
1504
+ }
959
1505
  const tag = el.tagName.toLowerCase();
960
1506
  const cls = namingClass(el);
961
1507
  if (cls) out.push({
@@ -963,6 +1509,11 @@ function inners(el) {
963
1509
  words: `the ${cls.replace(/[-_]/g, " ")}`,
964
1510
  count: (s) => s.querySelectorAll(`${tag}.${CSS.escape(cls)}`).length
965
1511
  });
1512
+ out.push({
1513
+ code: `locator(${jsString(tag)})`,
1514
+ words: `the ${TAG_WORDS[tag] ?? tag}`,
1515
+ count: (s) => s.querySelectorAll(tag).length
1516
+ });
966
1517
  return out;
967
1518
  }
968
1519
  /** A container, as a locator, and the elements that locator finds on the page. */
@@ -1010,12 +1561,12 @@ var SCOPE_LEVELS = 6;
1010
1561
  * holds only it and that its title tells apart from the others ("Celadon mug, 12 oz"). A card or a row is found by
1011
1562
  * its own title the same way.
1012
1563
  */
1013
- function scoped(el) {
1014
- const ways = inners(el);
1564
+ function scoped(el, keep) {
1565
+ const ways = inners(el, keep);
1015
1566
  const text = visibleText(el);
1016
1567
  const own = named(roleFor(el)) ? null : boxOf(el);
1017
1568
  const ownTitle = own && titleOf(el, null);
1018
- if (own && ownTitle && own.all().filter((c) => matchesText(c, ownTitle)).length === 1) return {
1569
+ if (own && ownTitle && keep(ownTitle) && own.all().filter((c) => matchesText(c, ownTitle)).length === 1) return {
1019
1570
  code: `${own.code}.filter({ hasText: ${jsString(ownTitle)} })`,
1020
1571
  description: `“${ownTitle}”`,
1021
1572
  text,
@@ -1050,10 +1601,10 @@ var ordinal = (n) => `${n}${n % 100 >= 11 && n % 100 <= 13 ? "th" : [
1050
1601
  "rd"
1051
1602
  ][n % 10] ?? "th"}`;
1052
1603
  /** The last resort: which of the same it is, counting from the top of the page. */
1053
- function nth(el) {
1604
+ function nth(el, keep) {
1054
1605
  const text = visibleText(el);
1055
1606
  const role = roleFor(el);
1056
- const name = named(role) ? accessibleName(el) : "";
1607
+ const name = named(role) && (!namedByText(el) || keep(accessibleName(el))) ? accessibleName(el) : "";
1057
1608
  const tag = el.tagName.toLowerCase();
1058
1609
  const cls = namingClass(el);
1059
1610
  const pick = (code, all, words) => {
@@ -1101,6 +1652,8 @@ function locatorFromInfo(role, label, text, tag) {
1101
1652
  */
1102
1653
  function buildLocator(el, opts = {}) {
1103
1654
  const text = visibleText(el);
1655
+ const keep = opts.check !== void 0 ? keeper(el, opts.check) : always;
1656
+ const byName = !namedByText(el) || keep === always;
1104
1657
  const testId = el.getAttribute("data-testid");
1105
1658
  if (testId && document.querySelectorAll(`[data-testid="${CSS.escape(testId)}"]`).length === 1) {
1106
1659
  const name = collapse((accessibleName(el) || text).replace(/[-−–+]?\s?[$€£¥]?\s?\d[\d.,]*%?/g, " "), 30);
@@ -1113,39 +1666,79 @@ function buildLocator(el, opts = {}) {
1113
1666
  const role = roleFor(el);
1114
1667
  if (named(role)) {
1115
1668
  const name = accessibleName(el);
1116
- if (name && !looksVolatile(name) && name.length <= 60) {
1117
- if (countByRoleAndName(role, name, false) === 1) return {
1118
- code: `page.getByRole(${jsString(role)}, { name: ${jsString(name)} })`,
1669
+ if (name && name.length <= 60) {
1670
+ const open = numbersOpen(name);
1671
+ if (open && (byName || keep(open)) && countByRoleAndName(role, open, true) === 1) return {
1672
+ code: `page.getByRole(${jsString(role)}, { name: ${open.code} })`,
1119
1673
  description: describeRole(role, name),
1120
1674
  text
1121
1675
  };
1122
- if (countByRoleAndName(role, name, true) === 1) return {
1123
- code: `page.getByRole(${jsString(role)}, { name: ${jsString(name)}, exact: true })`,
1124
- description: describeRole(role, name),
1676
+ if (!looksVolatile(name) && (byName || keep(name))) {
1677
+ if (countByRoleAndName(role, name, false) === 1) return {
1678
+ code: `page.getByRole(${jsString(role)}, { name: ${jsString(name)} })`,
1679
+ description: describeRole(role, name),
1680
+ text
1681
+ };
1682
+ if (countByRoleAndName(role, name, true) === 1) return {
1683
+ code: `page.getByRole(${jsString(role)}, { name: ${jsString(name)}, exact: true })`,
1684
+ description: describeRole(role, name),
1685
+ text
1686
+ };
1687
+ }
1688
+ } else if (name && byName) {
1689
+ const start = name.slice(0, 50).replace(/\s+\S*$/, "");
1690
+ if (start.length >= 12 && !looksVolatile(start) && countByRoleAndName(role, start, false) === 1) return {
1691
+ code: `page.getByRole(${jsString(role)}, { name: ${jsString(start)} })`,
1692
+ description: describeRole(role, `${start}…`),
1125
1693
  text
1126
1694
  };
1127
1695
  }
1128
1696
  }
1129
1697
  if (isField(el)) {
1130
- const label = labelText(el);
1131
- if (label && Array.from(document.querySelectorAll("input, textarea, select")).filter((f) => labelText(f) === label).length === 1) return {
1132
- code: `page.getByLabel(${jsString(label)})`,
1133
- description: `the “${label}” field`,
1698
+ const full = labelText(el);
1699
+ const label = full.replace(/…$/, "");
1700
+ if (label) {
1701
+ const by = (exact) => Array.from(document.querySelectorAll("input, textarea, select, [aria-label]")).filter((f) => sameWords(labelText(f) || f.getAttribute("aria-label") || "", label, exact)).length;
1702
+ if (by(false) === 1) return {
1703
+ code: `page.getByLabel(${jsString(label)})`,
1704
+ description: `the “${label}” field`,
1705
+ text
1706
+ };
1707
+ if (label === full && by(true) === 1) return {
1708
+ code: `page.getByLabel(${jsString(label)}, { exact: true })`,
1709
+ description: `the “${label}” field`,
1710
+ text
1711
+ };
1712
+ }
1713
+ const ph = el.getAttribute("placeholder");
1714
+ if (ph) {
1715
+ const by = (exact) => Array.from(document.querySelectorAll("[placeholder]")).filter((f) => sameWords(f.getAttribute("placeholder") ?? "", ph, exact)).length;
1716
+ if (by(false) === 1) return {
1717
+ code: `page.getByPlaceholder(${jsString(ph)})`,
1718
+ description: `the “${ph}” field`,
1719
+ text
1720
+ };
1721
+ if (by(true) === 1) return {
1722
+ code: `page.getByPlaceholder(${jsString(ph)}, { exact: true })`,
1723
+ description: `the “${ph}” field`,
1724
+ text
1725
+ };
1726
+ }
1727
+ }
1728
+ if ((!opts.cheap || document.getElementsByTagName("*").length < 2500) && text && text.length <= 40 && !isField(el)) {
1729
+ const open = numbersOpen(text);
1730
+ if (open && keep(open) && byText$1(document.body, open).length === 1) return {
1731
+ code: `page.getByText(${open.code})`,
1732
+ description: `“${text}”`,
1134
1733
  text
1135
1734
  };
1136
- const ph = el.getAttribute("placeholder");
1137
- if (ph && document.querySelectorAll(`[placeholder="${CSS.escape(ph)}"]`).length === 1) return {
1138
- code: `page.getByPlaceholder(${jsString(ph)})`,
1139
- description: `the “${ph}” field`,
1735
+ if (!looksVolatile(text) && keep(text) && byText$1(document.body, text).length === 1) return {
1736
+ code: `page.getByText(${jsString(text)}, { exact: true })`,
1737
+ description: `“${text}”`,
1140
1738
  text
1141
1739
  };
1142
1740
  }
1143
- if ((!opts.cheap || document.getElementsByTagName("*").length < 2500) && text && !looksVolatile(text) && text.length <= 40 && byText$1(document.body, text).length === 1) return {
1144
- code: `page.getByText(${jsString(text)}, { exact: true })`,
1145
- description: `“${text}”`,
1146
- text
1147
- };
1148
- return scoped(el) ?? nth(el);
1741
+ return scoped(el, keep) ?? nth(el, keep);
1149
1742
  }
1150
1743
  //#endregion
1151
1744
  //#region src/bridge/explore.ts
@@ -1175,20 +1768,39 @@ function fieldOf(el) {
1175
1768
  };
1176
1769
  return {
1177
1770
  type,
1178
- value: type === "password" ? el.value ? "••••" : "" : collapse(el.value, 60),
1179
- required: el.required
1771
+ value: collapse(fieldValue(el), 60),
1772
+ required: el.required,
1773
+ ...isSecretField(el) ? { secret: true } : {}
1180
1774
  };
1181
1775
  }
1776
+ /** What pressing Enter in a field sends its form with: the form's default button (its first submit button), else the form's own name. */
1777
+ function submitsOf(el) {
1778
+ if (!(el instanceof HTMLInputElement) || [
1779
+ "checkbox",
1780
+ "radio",
1781
+ "file",
1782
+ "submit",
1783
+ "button",
1784
+ "reset",
1785
+ "image"
1786
+ ].includes(el.type)) return void 0;
1787
+ const form = el.form;
1788
+ if (!form) return void 0;
1789
+ const button = form.querySelector("button:not([type]), button[type=\"submit\"], input[type=\"submit\"], input[type=\"image\"]");
1790
+ return collapse(button ? accessibleName(button) || visibleText(button) || button.value : accessibleName(form), 60) || void 0;
1791
+ }
1182
1792
  function control(el, offscreen) {
1183
1793
  const role = roleOf(el) ?? el.tagName.toLowerCase();
1184
1794
  const name = collapse(accessibleName(el) || visibleText(el) || el.getAttribute("placeholder") || el.getAttribute("title"), 60);
1185
1795
  const src = el.getAttribute("data-ds-src") ?? annotatedAncestor(el)?.getAttribute("data-ds-src") ?? null;
1186
1796
  const disabled = el.disabled === true || el.getAttribute("aria-disabled") === "true";
1797
+ const submits = submitsOf(el);
1187
1798
  return {
1188
1799
  locator: buildLocator(el, { cheap: true }).code,
1189
1800
  role,
1190
1801
  name,
1191
1802
  ...isField(el) ? { field: fieldOf(el) } : {},
1803
+ ...submits ? { submits } : {},
1192
1804
  disabled,
1193
1805
  offscreen,
1194
1806
  src
@@ -1212,10 +1824,10 @@ function snapshot() {
1212
1824
  const listed = placed.slice(0, MAX_CONTROLS);
1213
1825
  const focused = listed.findIndex((p) => p.el === document.activeElement);
1214
1826
  return {
1215
- url: location.href,
1827
+ url: redactUrl(location.href),
1216
1828
  title: document.title,
1217
1829
  headings: Array.from(document.querySelectorAll("h1, h2, h3")).filter(isVisible).slice(0, 12).map((h) => collapse(h.textContent, 100)),
1218
- text: collapse(main.innerText, MAX_TEXT),
1830
+ text: collapse(maskSecrets(main.innerText), MAX_TEXT),
1219
1831
  controls: listed.map((p) => control(p.el, p.offscreen)),
1220
1832
  more: Math.max(0, placed.length - MAX_CONTROLS),
1221
1833
  dialog: dialog ? collapse(accessibleName(dialog) || dialog.querySelector("h1, h2, h3")?.textContent, 80) || "a dialog" : null,
@@ -1341,10 +1953,10 @@ function probe(x, y, radius, deep = false) {
1341
1953
  }
1342
1954
  var held = null;
1343
1955
  /**
1344
- * For a hand: the highlight stays on what it is on until the point clearly
1345
- * leaves it (a few pixels past its edge) or moves onto something inside it.
1346
- * A fingertip wobbles; without this the highlight flickers between
1347
- * neighbours, and a pinch selects whichever one it happened to be on.
1956
+ * For an unsteady pointer (an older studio's hand control asks for it): the
1957
+ * highlight stays on what it is on until the point clearly leaves it (a few
1958
+ * pixels past its edge) or moves onto something inside it. Without this the
1959
+ * highlight flickers between neighbours.
1348
1960
  */
1349
1961
  function probeSticky(x, y, radius, deep = false) {
1350
1962
  const hit = probe(x, y, radius, deep);
@@ -1457,6 +2069,7 @@ var KINDS = {
1457
2069
  "textbox",
1458
2070
  "searchbox",
1459
2071
  "combobox",
2072
+ "listbox",
1460
2073
  "field"
1461
2074
  ],
1462
2075
  list: [
@@ -1615,9 +2228,22 @@ function parseLocator(code) {
1615
2228
  i++;
1616
2229
  return out;
1617
2230
  };
2231
+ /** A regular expression literal, as the recorder writes one for a name with its numbers left open: `/^Pay \$\d[\d.,]*$/`. */
2232
+ const regex = () => {
2233
+ const m = /^\/((?:[^/\\[\r\n]|\\.|\[(?:[^\]\\\r\n]|\\.)*\])+)\/([dgimsuy]*)/.exec(s.slice(i));
2234
+ if (!m) return null;
2235
+ try {
2236
+ const re = new RegExp(m[1], m[2].replace(/[gy]/g, ""));
2237
+ i += m[0].length;
2238
+ return re;
2239
+ } catch {
2240
+ return null;
2241
+ }
2242
+ };
1618
2243
  const value = () => {
1619
2244
  ws();
1620
2245
  if (s[i] === "'" || s[i] === "\"") return str();
2246
+ if (s[i] === "/") return regex();
1621
2247
  const num = /^-?\d+(?:\.\d+)?/.exec(s.slice(i));
1622
2248
  if (num) {
1623
2249
  i += num[0].length;
@@ -1641,7 +2267,7 @@ function parseLocator(code) {
1641
2267
  if (!key || s[i] !== ":") return null;
1642
2268
  i++;
1643
2269
  const v = value();
1644
- if (v === null || typeof v === "object") return null;
2270
+ if (v === null || typeof v === "object" && !(v instanceof RegExp)) return null;
1645
2271
  obj[key] = v;
1646
2272
  ws();
1647
2273
  if (s[i] === ",") i++;
@@ -1683,21 +2309,25 @@ function parseLocator(code) {
1683
2309
  return calls.length ? calls : null;
1684
2310
  }
1685
2311
  var norm = (s) => s.replace(/\s+/g, " ").trim();
1686
- /** Playwright's matching: case-insensitive and partial by default, exact (case and all) when asked. */
2312
+ /** Playwright's matching: case-insensitive and partial by default, exact (case and all) when asked; a pattern as it is. */
1687
2313
  function matches(actual, wanted, exact) {
1688
2314
  const a = norm(actual);
2315
+ if (wanted instanceof RegExp) return wanted.test(a);
1689
2316
  const w = norm(wanted);
1690
2317
  return exact ? a === w : a.toLowerCase().includes(w.toLowerCase());
1691
2318
  }
2319
+ /** A pattern loosened to find what holds a match somewhere in its text (`^…$` let go). */
2320
+ var loose = (re) => new RegExp(re.source.replace(/^\^/, "").replace(/(?<!\\)\$$/, ""), re.flags);
1692
2321
  /** Innermost elements whose text is (or, not exact, contains) `text`. */
1693
2322
  function byText(scope, text, exact) {
1694
- const needle = norm(text).toLowerCase();
2323
+ const needle = typeof text === "string" ? norm(text).toLowerCase() : "";
2324
+ const somewhere = text instanceof RegExp ? loose(text) : null;
1695
2325
  const hits = [];
1696
2326
  const all = scope.querySelectorAll("body *");
1697
2327
  for (let i = 0; i < all.length; i++) {
1698
2328
  const el = all[i];
1699
2329
  if (el instanceof HTMLScriptElement || el instanceof HTMLStyleElement) continue;
1700
- if (!norm(el.textContent ?? "").toLowerCase().includes(needle)) continue;
2330
+ if (somewhere ? !somewhere.test(norm(el.textContent ?? "")) : !norm(el.textContent ?? "").toLowerCase().includes(needle)) continue;
1701
2331
  if (!matches(isField(el) ? "" : visibleText(el), text, exact)) continue;
1702
2332
  hits.push(el);
1703
2333
  }
@@ -1749,29 +2379,30 @@ function elementsFor(calls, like) {
1749
2379
  };
1750
2380
  for (const c of calls) {
1751
2381
  const [a, b] = c.args;
1752
- const opt = typeof b === "object" ? b : {};
2382
+ const opt = typeof b === "object" && !(b instanceof RegExp) ? b : {};
2383
+ const text = (v) => v instanceof RegExp ? v : String(v);
1753
2384
  switch (c.name) {
1754
2385
  case "getByTestId":
1755
2386
  set = within((sc) => Array.from(sc.querySelectorAll(`[data-testid="${CSS.escape(String(a))}"]`)));
1756
2387
  break;
1757
2388
  case "getByRole": {
1758
2389
  const role = String(a);
1759
- set = within((sc) => Array.from(sc.querySelectorAll(ROLE_SELECTORS[role] ?? `[role="${CSS.escape(role)}"]`)).filter((el) => roleFor(el) === role && (opt.name == null || matches(accessibleName(el), String(opt.name), !!opt.exact))));
2390
+ set = within((sc) => Array.from(sc.querySelectorAll(ROLE_SELECTORS[role] ?? `[role="${CSS.escape(role)}"]`)).filter((el) => roleFor(el) === role && (opt.name == null || matches(accessibleName(el), text(opt.name), !!opt.exact))));
1760
2391
  break;
1761
2392
  }
1762
2393
  case "filter": {
1763
- const f = typeof a === "object" ? a : {};
1764
- set = (set ?? []).filter((el) => (f.hasText == null || matches(el.textContent ?? "", String(f.hasText), false)) && (f.hasNotText == null || !matches(el.textContent ?? "", String(f.hasNotText), false)));
2394
+ const f = typeof a === "object" && !(a instanceof RegExp) ? a : {};
2395
+ set = (set ?? []).filter((el) => (f.hasText == null || matches(el.textContent ?? "", text(f.hasText), false)) && (f.hasNotText == null || !matches(el.textContent ?? "", text(f.hasNotText), false)));
1765
2396
  break;
1766
2397
  }
1767
2398
  case "getByLabel":
1768
- set = within((sc) => Array.from(sc.querySelectorAll("input, textarea, select, [aria-label]")).filter((el) => matches(labelText(el) || el.getAttribute("aria-label") || "", String(a), !!opt.exact)));
2399
+ set = within((sc) => Array.from(sc.querySelectorAll("input, textarea, select, [aria-label]")).filter((el) => matches(labelText(el) || el.getAttribute("aria-label") || "", text(a), !!opt.exact)));
1769
2400
  break;
1770
2401
  case "getByPlaceholder":
1771
- set = within((sc) => Array.from(sc.querySelectorAll("[placeholder]")).filter((el) => matches(el.getAttribute("placeholder") ?? "", String(a), !!opt.exact)));
2402
+ set = within((sc) => Array.from(sc.querySelectorAll("[placeholder]")).filter((el) => matches(el.getAttribute("placeholder") ?? "", text(a), !!opt.exact)));
1772
2403
  break;
1773
2404
  case "getByText":
1774
- set = within((sc) => byText(sc, String(a), !!opt.exact));
2405
+ set = within((sc) => byText(sc, text(a), !!opt.exact));
1775
2406
  break;
1776
2407
  case "locator":
1777
2408
  try {
@@ -1812,14 +2443,34 @@ function find(code, like) {
1812
2443
  reason: all.length ? "hidden" : "missing"
1813
2444
  };
1814
2445
  }
2446
+ /** The option a recorded choice names: by its value, else by what it says (as it was recorded, "Express"). */
2447
+ function optionFor(el, wanted) {
2448
+ const opts = Array.from(el.options);
2449
+ const said = (o) => norm(o.label || o.text);
2450
+ return opts.find((x) => x.value === wanted) ?? opts.find((x) => said(x) === norm(wanted)) ?? opts.find((x) => said(x).toLowerCase() === norm(wanted).toLowerCase());
2451
+ }
2452
+ /** Choose these options (all of them, in a list that takes several), as a person would: then input and change. */
2453
+ function choose(el, wanted) {
2454
+ const picked = wanted.map((w) => optionFor(el, w));
2455
+ if (!picked.length || picked.some((o) => !o)) throw new Error("no-option");
2456
+ if (el.multiple) for (const o of Array.from(el.options)) o.selected = picked.includes(o);
2457
+ else {
2458
+ const setter = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, "value")?.set;
2459
+ if (setter) setter.call(el, picked[0].value);
2460
+ else el.value = picked[0].value;
2461
+ }
2462
+ el.dispatchEvent(new Event("input", { bubbles: true }));
2463
+ el.dispatchEvent(new Event("change", { bubbles: true }));
2464
+ }
2465
+ /** A control a person couldn't use now: disabled (itself or its fieldset), marked so, or in an inert part of the page. */
2466
+ function unusable(el) {
2467
+ if (el.matches(":disabled")) return true;
2468
+ if (el.closest("[inert]")) return true;
2469
+ return el.closest("[aria-disabled]")?.getAttribute("aria-disabled") === "true";
2470
+ }
1815
2471
  /** Set a field's value the way React expects (native setter, then input and change). */
1816
2472
  function setValue(el, value) {
1817
- if (el instanceof HTMLSelectElement) {
1818
- const opts = Array.from(el.options);
1819
- const o = opts.find((x) => x.value === value) ?? opts.find((x) => norm(x.text) === norm(value)) ?? opts.find((x) => norm(x.text).toLowerCase() === norm(value).toLowerCase());
1820
- if (!o) throw new Error("no-option");
1821
- value = o.value;
1822
- }
2473
+ if (el instanceof HTMLSelectElement) return choose(el, [value]);
1823
2474
  if (el instanceof HTMLInputElement && el.type === "file") throw new Error("file");
1824
2475
  const proto = el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : el instanceof HTMLSelectElement ? HTMLSelectElement.prototype : HTMLInputElement.prototype;
1825
2476
  const setter = Object.getOwnPropertyDescriptor(proto, "value")?.set;
@@ -1850,8 +2501,13 @@ var mouse = (type, x, y) => new MouseEvent(type, {
1850
2501
  buttons: type === "mousedown" ? 1 : 0,
1851
2502
  detail: 1
1852
2503
  });
1853
- /** Do one step on an element: as a person would, so the app (and its recorder) sees the same events. */
2504
+ /**
2505
+ * Do one step on an element: as a person would, so the app (and its recorder) sees the same events. A control a
2506
+ * person couldn't use now (disabled, inert) is refused: clicking it would do nothing, and the replay would go on as
2507
+ * if it had worked.
2508
+ */
1854
2509
  function act(el, step) {
2510
+ if (unusable(el)) throw new Error("disabled");
1855
2511
  const r = el.getBoundingClientRect();
1856
2512
  const x = r.left + r.width / 2;
1857
2513
  const y = r.top + r.height / 2;
@@ -1873,6 +2529,7 @@ function act(el, step) {
1873
2529
  if (el.checked !== (step.checked ?? !el.checked)) el.click();
1874
2530
  return;
1875
2531
  }
2532
+ if (el instanceof HTMLSelectElement && step.select?.length) return choose(el, step.select);
1876
2533
  setValue(el, step.value ?? "");
1877
2534
  return;
1878
2535
  }
@@ -1903,9 +2560,10 @@ function act(el, step) {
1903
2560
  if (down && key === "Enter" && el instanceof HTMLInputElement) el.form?.requestSubmit();
1904
2561
  }
1905
2562
  }
1906
- /** What an element shows now, for checking what the tester said it should show. */
2563
+ /** What an element shows now, for checking what the tester said it should show (a secret as dots). */
1907
2564
  function textOf$1(el) {
1908
- return isField(el) ? el.value : collapse(el.innerText ?? el.textContent ?? "", 400);
2565
+ if (el instanceof HTMLSelectElement) return Array.from(el.selectedOptions, (o) => norm(o.label || o.text)).join(", ");
2566
+ return isField(el) ? fieldValue(el) : collapse(el.innerText ?? el.textContent ?? "", 400);
1909
2567
  }
1910
2568
  //#endregion
1911
2569
  //#region src/bridge/forms.ts
@@ -1961,13 +2619,37 @@ var TEXTUAL = /* @__PURE__ */ new Set([
1961
2619
  "number",
1962
2620
  ""
1963
2621
  ]);
2622
+ /** Inputs whose value is picked rather than typed: a test sets it (fill does, as the input keeps it: 2026-11-20). */
2623
+ var PICKED = /* @__PURE__ */ new Set([
2624
+ "date",
2625
+ "time",
2626
+ "datetime-local",
2627
+ "month",
2628
+ "week",
2629
+ "color",
2630
+ "range"
2631
+ ]);
2632
+ var KEY_NAME = {
2633
+ ArrowDown: "↓",
2634
+ ArrowUp: "↑",
2635
+ Escape: "Escape"
2636
+ };
1964
2637
  function isTextField(el) {
1965
2638
  if (el instanceof HTMLTextAreaElement) return true;
1966
2639
  return el instanceof HTMLInputElement && TEXTUAL.has((el.type || "").toLowerCase());
1967
2640
  }
2641
+ var isPicked = (el) => el instanceof HTMLInputElement && PICKED.has((el.type || "").toLowerCase());
2642
+ var isToggle = (el) => el instanceof HTMLInputElement && (el.type === "checkbox" || el.type === "radio");
2643
+ /** A field that offers a list to choose from as you type (a WAI-ARIA combobox, or an input with a datalist). */
2644
+ var isCombobox = (el) => el.getAttribute("role") === "combobox" || el.hasAttribute("aria-autocomplete") || el instanceof HTMLInputElement && !!el.list;
2645
+ /** What an option is called: its label, else its text. */
2646
+ var optionName = (o) => collapse(o.label || o.text, 80);
1968
2647
  /**
1969
2648
  * Records what the user does in the app as Playwright steps: clicks, text
1970
- * entry (one `fill` per field, flushed on change, blur or Enter) and Enter.
2649
+ * entry (one `fill` per field, flushed on change, blur or Enter), choices in
2650
+ * lists (every option chosen, in a list that takes several) and Enter. A
2651
+ * secret (a password, a card number…) is recorded by its name alone: what was
2652
+ * typed never leaves the page.
1971
2653
  */
1972
2654
  var Recorder = class {
1973
2655
  emit;
@@ -1976,24 +2658,41 @@ var Recorder = class {
1976
2658
  submits = new EnterSubmits();
1977
2659
  constructor(emit) {
1978
2660
  this.emit = emit;
2661
+ document.addEventListener("pointerdown", this.onPointerDown, true);
1979
2662
  document.addEventListener("click", this.onClick, true);
1980
2663
  document.addEventListener("input", this.onInput, true);
1981
2664
  document.addEventListener("change", this.onChange, true);
1982
2665
  document.addEventListener("keydown", this.onKeyDown, true);
1983
2666
  document.addEventListener("blur", this.onBlur, true);
2667
+ document.addEventListener("focusin", this.onFocus, true);
1984
2668
  }
1985
2669
  setOn(on) {
1986
2670
  if (!on) this.flushAll();
1987
2671
  this.on = on;
1988
2672
  }
2673
+ /** Send the text still being typed now: what comes next (a check said about the page) comes after it. */
2674
+ flushAll() {
2675
+ for (const el of Array.from(this.pendingFill.keys())) this.flush(el);
2676
+ }
2677
+ onFocus = (e) => {
2678
+ if (this.on && e.target instanceof Element && isField(e.target)) isSecretField(e.target);
2679
+ };
2680
+ /** What was being typed comes before what is clicked now: a combobox's suggestion keeps the focus in its field. */
2681
+ onPointerDown = (e) => {
2682
+ if (!this.on) return;
2683
+ const t = e.target instanceof Node ? e.target : null;
2684
+ for (const el of Array.from(this.pendingFill.keys())) if (!t || !el.contains(t)) this.flush(el);
2685
+ };
1989
2686
  onClick = (e) => {
1990
2687
  if (!this.on) return;
1991
2688
  const t = e.target instanceof Element ? e.target : null;
1992
2689
  if (!t) return;
1993
2690
  const el = t.closest(INTERACTIVE$1) ?? t;
1994
- if (isTextField(el)) return;
2691
+ if (isTextField(el) || isPicked(el)) return;
2692
+ if (isToggle(el)) return;
2693
+ if (el instanceof HTMLSelectElement || el instanceof HTMLOptionElement) return;
1995
2694
  if (this.submits.isSubmitClick(e, el)) return;
1996
- if (el instanceof HTMLLabelElement && el.control && isTextField(el.control)) return;
2695
+ if (el instanceof HTMLLabelElement && el.control && (isTextField(el.control) || el.control instanceof HTMLSelectElement || isPicked(el.control) || isToggle(el.control))) return;
1997
2696
  const loc = buildLocator(el);
1998
2697
  this.emit({
1999
2698
  kind: "click",
@@ -2014,13 +2713,32 @@ var Recorder = class {
2014
2713
  const el = e.target;
2015
2714
  if (!(el instanceof Element)) return;
2016
2715
  if (isTextField(el)) this.flush(el);
2017
- else if (el instanceof HTMLSelectElement) {
2716
+ else if (isPicked(el)) {
2717
+ const prev = this.pendingFill.get(el);
2718
+ if (prev) clearTimeout(prev);
2719
+ this.pendingFill.set(el, setTimeout(() => this.flush(el), 600));
2720
+ } else if (isToggle(el)) {
2018
2721
  const loc = buildLocator(el);
2722
+ const did = el.type === "radio" ? "Choose" : el.checked ? "Check" : "Uncheck";
2019
2723
  this.emit({
2020
2724
  kind: "fill",
2021
2725
  locator: loc.code,
2022
- value: el.value,
2023
- description: `Choose “${el.value}” in ${loc.description}`
2726
+ value: "",
2727
+ checked: el.checked,
2728
+ description: `${did} ${loc.description}`
2729
+ });
2730
+ } else if (el instanceof HTMLSelectElement) {
2731
+ const loc = buildLocator(el);
2732
+ const chosen = Array.from(el.selectedOptions);
2733
+ const names = chosen.map(optionName);
2734
+ const said = names.length ? names.map((n) => `“${n}”`).join(", ") : "nothing";
2735
+ this.emit({
2736
+ kind: "fill",
2737
+ locator: loc.code,
2738
+ value: chosen[0]?.value ?? "",
2739
+ select: names,
2740
+ values: chosen.map((o) => o.value),
2741
+ description: `Choose ${said} in ${loc.description}`
2024
2742
  });
2025
2743
  }
2026
2744
  };
@@ -2030,7 +2748,21 @@ var Recorder = class {
2030
2748
  if (el instanceof Element && this.pendingFill.has(el)) this.flush(el);
2031
2749
  };
2032
2750
  onKeyDown = (e) => {
2033
- if (!this.on || e.key !== "Enter") return;
2751
+ if (!this.on) return;
2752
+ if (KEY_NAME[e.key]) {
2753
+ const el = e.target;
2754
+ if (!(el instanceof Element) || !isCombobox(el)) return;
2755
+ if (this.pendingFill.has(el)) this.flush(el);
2756
+ const loc = buildLocator(el);
2757
+ this.emit({
2758
+ kind: "press",
2759
+ locator: loc.code,
2760
+ key: e.key,
2761
+ description: `Press ${KEY_NAME[e.key]} in ${loc.description}`
2762
+ });
2763
+ return;
2764
+ }
2765
+ if (e.key !== "Enter") return;
2034
2766
  this.submits.keydown(e);
2035
2767
  const el = e.target;
2036
2768
  if (!(el instanceof Element) || !isField(el)) return;
@@ -2047,18 +2779,36 @@ var Recorder = class {
2047
2779
  const t = this.pendingFill.get(el);
2048
2780
  if (t) clearTimeout(t);
2049
2781
  if (!this.pendingFill.delete(el)) return;
2782
+ if (isPicked(el)) {
2783
+ const loc = buildLocator(el);
2784
+ this.emit({
2785
+ kind: "fill",
2786
+ locator: loc.code,
2787
+ value: el.value,
2788
+ description: `Set ${loc.description} to “${collapse(el.value, 60)}”`
2789
+ });
2790
+ return;
2791
+ }
2050
2792
  if (!isTextField(el)) return;
2051
2793
  const loc = buildLocator(el);
2794
+ if (isSecretField(el)) {
2795
+ this.emit({
2796
+ kind: "fill",
2797
+ locator: loc.code,
2798
+ value: "",
2799
+ secret: secretNameOf(el),
2800
+ description: `Type ${MASK} into ${loc.description}`
2801
+ });
2802
+ return;
2803
+ }
2804
+ const value = maskSecrets(el.value);
2052
2805
  this.emit({
2053
2806
  kind: "fill",
2054
2807
  locator: loc.code,
2055
- value: el.value,
2056
- description: `Type “${el.value}” into ${loc.description}`
2808
+ value,
2809
+ description: `Type “${collapse(value, 60)}” into ${loc.description}`
2057
2810
  });
2058
2811
  }
2059
- flushAll() {
2060
- for (const el of Array.from(this.pendingFill.keys())) this.flush(el);
2061
- }
2062
2812
  };
2063
2813
  //#endregion
2064
2814
  //#region src/bridge/own.ts
@@ -2089,6 +2839,19 @@ function ownTime(start, end) {
2089
2839
  }
2090
2840
  //#endregion
2091
2841
  //#region src/bridge/proxy.ts
2842
+ /**
2843
+ * When the studio shows a site through its proxy, the site's own absolute
2844
+ * addresses (https://example.com/...) would leave the proxy: requests,
2845
+ * sockets, workers, links, forms and navigations are pointed back into it
2846
+ * before the site's scripts run. A site on this computer that calls another
2847
+ * port of it (its API at http://localhost:5010) has those requests go through
2848
+ * the proxy too: from the proxy's address they would meet the API's CORS
2849
+ * rules as a stranger. The proxy applies those rules as the browser would
2850
+ * (each request says whether it carries the page's credentials). Cookies are kept under the site's own prefix in the
2851
+ * browser (cookies ignore ports, and other apps share localhost): the site's
2852
+ * scripts see their own names. Service workers are kept off, so an old cached
2853
+ * page never hides the studio.
2854
+ */
2092
2855
  /** This computer's own names. */
2093
2856
  var LOOPBACK$1 = /^(localhost|127\.0\.0\.1|\[::1\])$/;
2094
2857
  var portOf = (u) => u.port || (u.protocol === "https:" || u.protocol === "wss:" ? "443" : "80");
@@ -2098,7 +2861,7 @@ var portOf = (u) => u.port || (u.protocol === "https:" || u.protocol === "wss:"
2098
2861
  * site is on this computer: another port of it → the proxy's path for that port, /__saywright/<http|https>/<host:port>/…,
2099
2862
  * so the request leaves from the site's own address, as it does outside the studio. Anything else stays as it is.
2100
2863
  */
2101
- function viaProxy(input, base, c, requests) {
2864
+ function viaProxy(input, base, c, requests, credentials = false) {
2102
2865
  try {
2103
2866
  const u = new URL(input, base);
2104
2867
  const up = new URL(c.upstream);
@@ -2111,7 +2874,7 @@ function viaProxy(input, base, c, requests) {
2111
2874
  if (!requests || !/^(https?|wss?):$/.test(u.protocol) || !LOOPBACK$1.test(up.hostname) || !LOOPBACK$1.test(u.hostname)) return input;
2112
2875
  if (portOf(u) === portOf(proxy)) return input;
2113
2876
  if (c.studio && portOf(u) === portOf(new URL(c.studio))) return input;
2114
- const scheme = u.protocol === "https:" || u.protocol === "wss:" ? "https" : "http";
2877
+ const scheme = (u.protocol === "https:" || u.protocol === "wss:" ? "https" : "http") + (credentials ? "+cred" : "");
2115
2878
  return `${u.protocol.startsWith("ws") ? "ws:" : proxy.protocol}//${proxy.host}/__saywright/${scheme}/${u.hostname}:${portOf(u)}` + u.pathname + u.search + u.hash;
2116
2879
  } catch {
2117
2880
  return input;
@@ -2124,22 +2887,35 @@ function proxyConfig() {
2124
2887
  function installProxyRewrites(c) {
2125
2888
  /** Navigations, workers, history: the site's own address only. */
2126
2889
  const to = (input) => viaProxy(input, location.href, c, false);
2127
- /** Requests: its API on another port of this computer too. */
2128
- const toRequest = (input) => viaProxy(input, location.href, c, true);
2890
+ /** Requests: its API on another port of this computer too, saying whether they carry the page's credentials. */
2891
+ const toRequest = (input, credentials = false) => viaProxy(input, location.href, c, true, credentials);
2129
2892
  const mapped = (fn) => (url) => typeof url === "string" ? fn(url) : url instanceof URL ? new URL(fn(url.href)) : url;
2130
2893
  const toAny = mapped(to);
2131
- const requestAny = mapped(toRequest);
2894
+ const requestAny = mapped((u) => toRequest(u));
2895
+ /** Sockets and beacons always carry the site's cookies. */
2896
+ const requestWithCredentials = mapped((u) => toRequest(u, true));
2132
2897
  const nativeFetch = window.fetch.bind(window);
2133
2898
  window.fetch = (input, init) => {
2899
+ const credentials = (init?.credentials ?? (input instanceof Request ? input.credentials : "same-origin")) === "include";
2134
2900
  if (input instanceof Request) {
2135
- const next = toRequest(input.url);
2901
+ const next = toRequest(input.url, credentials);
2136
2902
  return nativeFetch(next === input.url ? input : new Request(next, input), init);
2137
2903
  }
2138
- return nativeFetch(requestAny(input), init);
2904
+ const url = typeof input === "string" ? toRequest(input, credentials) : new URL(toRequest(input.href, credentials));
2905
+ return nativeFetch(url, init);
2139
2906
  };
2907
+ const proxied = /* @__PURE__ */ new WeakSet();
2140
2908
  const open = XMLHttpRequest.prototype.open;
2141
2909
  XMLHttpRequest.prototype.open = function(method, url, ...rest) {
2142
- return open.call(this, method, requestAny(url), ...rest);
2910
+ const next = requestAny(url);
2911
+ if (String(next).includes("/__saywright/")) proxied.add(this);
2912
+ else proxied.delete(this);
2913
+ return open.call(this, method, next, ...rest);
2914
+ };
2915
+ const send = XMLHttpRequest.prototype.send;
2916
+ XMLHttpRequest.prototype.send = function(body) {
2917
+ if (proxied.has(this) && this.withCredentials) this.setRequestHeader(PROXY_CREDENTIALS_HEADER, "include");
2918
+ return send.call(this, body);
2143
2919
  };
2144
2920
  const wrap = (Native, map) => new Proxy(Native, { construct(target, args) {
2145
2921
  if (args.length) args[0] = map(args[0]);
@@ -2147,17 +2923,20 @@ function installProxyRewrites(c) {
2147
2923
  } });
2148
2924
  const w = window;
2149
2925
  for (const [name, map] of [
2150
- ["WebSocket", requestAny],
2151
- ["EventSource", requestAny],
2926
+ ["WebSocket", requestWithCredentials],
2152
2927
  ["Worker", toAny],
2153
2928
  ["SharedWorker", toAny]
2154
2929
  ]) {
2155
2930
  const Native = w[name];
2156
2931
  if (Native) w[name] = wrap(Native, map);
2157
2932
  }
2933
+ if (w.EventSource) w.EventSource = new Proxy(w.EventSource, { construct(target, args) {
2934
+ if (args.length) args[0] = args[1]?.withCredentials ? requestWithCredentials(args[0]) : requestAny(args[0]);
2935
+ return Reflect.construct(target, args);
2936
+ } });
2158
2937
  if (navigator.sendBeacon) {
2159
2938
  const beacon = navigator.sendBeacon.bind(navigator);
2160
- navigator.sendBeacon = (url, data) => beacon(requestAny(url), data);
2939
+ navigator.sendBeacon = (url, data) => beacon(requestWithCredentials(url), data);
2161
2940
  }
2162
2941
  for (const name of ["pushState", "replaceState"]) {
2163
2942
  const native = History.prototype[name];
@@ -2248,6 +3027,73 @@ function installCookieNamespace(prefix) {
2248
3027
  };
2249
3028
  store.delete = (arg) => del(named(arg));
2250
3029
  }
3030
+ //#endregion
3031
+ //#region src/bridge/mask.ts
3032
+ /**
3033
+ * While a session records, the screen it shares shows every secret field as dots and blurs what is marked private:
3034
+ * its screenshots and video are pictures of the screen, so this is what keeps a card number, a one-time code or a
3035
+ * key out of them, as the recorded text keeps it out of the events. The same rules decide what is secret (see dom.ts).
3036
+ */
3037
+ var ATTR = "data-saywright-masked";
3038
+ var STYLE_ID = "saywright-mask";
3039
+ var observer = null;
3040
+ function mark(el) {
3041
+ if (isField(el) && !(el instanceof HTMLSelectElement) && isSecretField(el)) el.setAttribute(ATTR, "");
3042
+ }
3043
+ function scan(root) {
3044
+ for (const el of Array.from(root.querySelectorAll("input, textarea"))) mark(el);
3045
+ }
3046
+ /** A field can become secret as it is used: a card number typed into one, a password shown as text. */
3047
+ var onUse = (e) => {
3048
+ if (e.target instanceof Element) mark(e.target);
3049
+ };
3050
+ function maskScreen(on) {
3051
+ if (typeof document === "undefined") return;
3052
+ if (!on) {
3053
+ observer?.disconnect();
3054
+ observer = null;
3055
+ removeEventListener("input", onUse, true);
3056
+ removeEventListener("focusin", onUse, true);
3057
+ document.getElementById(STYLE_ID)?.remove();
3058
+ for (const el of Array.from(document.querySelectorAll(`[${ATTR}]`))) el.removeAttribute(ATTR);
3059
+ return;
3060
+ }
3061
+ if (observer) return;
3062
+ const style = document.createElement("style");
3063
+ style.id = STYLE_ID;
3064
+ style.textContent = `[${ATTR}]{-webkit-text-security:disc!important}${PRIVATE.split(",").map((s) => `${s.trim()}:not(input):not(textarea)`).join(",")}{filter:blur(6px)!important}`;
3065
+ document.documentElement.appendChild(style);
3066
+ scan(document);
3067
+ observer = new MutationObserver((records) => {
3068
+ for (const r of records) {
3069
+ if (r.type === "attributes") {
3070
+ if (r.target instanceof Element) mark(r.target);
3071
+ continue;
3072
+ }
3073
+ for (const n of Array.from(r.addedNodes)) {
3074
+ if (!(n instanceof Element)) continue;
3075
+ mark(n);
3076
+ scan(n);
3077
+ }
3078
+ }
3079
+ });
3080
+ observer.observe(document.documentElement, {
3081
+ childList: true,
3082
+ subtree: true,
3083
+ attributes: true,
3084
+ attributeFilter: [
3085
+ "type",
3086
+ "name",
3087
+ "id",
3088
+ "autocomplete",
3089
+ "aria-label",
3090
+ "placeholder",
3091
+ "inputmode"
3092
+ ]
3093
+ });
3094
+ addEventListener("input", onUse, true);
3095
+ addEventListener("focusin", onUse, true);
3096
+ }
2251
3097
  /** What a request that may outlive the page can carry: browsers allow 64 KB for all of them together. */
2252
3098
  var KEEPALIVE_BYTES = 6e4;
2253
3099
  /** How soon a send follows an event that matters now (an error, a page change), and any other. */
@@ -2577,8 +3423,6 @@ var RequestTally = class {
2577
3423
  var ACTIVE_KEY = "saywright.session";
2578
3424
  var PENDING_KEY = "saywright.session.pending";
2579
3425
  var INTERACTIVE = "button, a[href], input, select, textarea, summary, label, [role=\"button\"], [role=\"link\"], [role=\"tab\"], [role=\"menuitem\"], [role=\"checkbox\"], [role=\"radio\"], [role=\"switch\"], [role=\"option\"], [contenteditable=\"true\"], [tabindex]:not([tabindex=\"-1\"])";
2580
- /** Fields whose value is never recorded (by name, id or autocomplete: "password", "cc-number", "card_cvc"…). */
2581
- var SECRET = /pass|secret|token|otp|cvv|cvc|csc|card|credit|\bcc-|cc-?num|cc-?exp|iban|ssn|pin\b/i;
2582
3426
  /** Requests that are the dev server or the studio talking, not the app. */
2583
3427
  var PLUMBING_URL = /\/@vite\/|\/@fs\/|\/@id\/|\/@react-refresh|\/node_modules\/|__saywright|\.hot-update\.|\/_next\/webpack-hmr|\/__nextjs|sockjs-node|\/api\/sessions\/|\/api\/speech\//;
2584
3428
  /** Where a session's events go. */
@@ -2757,6 +3601,7 @@ var SessionRecorder = class {
2757
3601
  writeJson(ACTIVE_KEY, null);
2758
3602
  this.active = null;
2759
3603
  this.outbox = null;
3604
+ maskScreen(false);
2760
3605
  outbox?.retire();
2761
3606
  this.report();
2762
3607
  }
@@ -2766,6 +3611,7 @@ var SessionRecorder = class {
2766
3611
  writeJson(ACTIVE_KEY, null);
2767
3612
  this.active = null;
2768
3613
  this.outbox = null;
3614
+ maskScreen(false);
2769
3615
  this.report();
2770
3616
  }
2771
3617
  /** Record what is still being put together: a burst's count, a hover, the pointer's trail, typing, counted requests. */
@@ -2789,6 +3635,7 @@ var SessionRecorder = class {
2789
3635
  }
2790
3636
  begin(info, resumed) {
2791
3637
  this.active = info;
3638
+ maskScreen(true);
2792
3639
  this.outbox = new Outbox(this.page, {
2793
3640
  post: (body, keepalive) => deliver(eventsUrl(info), body, keepalive),
2794
3641
  ended: () => this.endedElsewhere(info.id),
@@ -2829,11 +3676,11 @@ var SessionRecorder = class {
2829
3676
  }
2830
3677
  record(body, t, urgent = false) {
2831
3678
  if (!this.active || !this.outbox) return;
2832
- this.outbox.push({
3679
+ this.outbox.push(scrubEvent({
2833
3680
  ...body,
2834
3681
  seq: ++this.seq,
2835
3682
  t: Math.round(t)
2836
- }, urgent);
3683
+ }), urgent);
2837
3684
  this.stats.events++;
2838
3685
  if ([
2839
3686
  "error",
@@ -3083,7 +3930,7 @@ var SessionRecorder = class {
3083
3930
  onInput = (e) => {
3084
3931
  if (!this.active || !(e.target instanceof Element) || !isField(e.target) || ours(e.target)) return;
3085
3932
  const el = e.target;
3086
- if (el instanceof HTMLInputElement && [
3933
+ if (el instanceof HTMLSelectElement || el instanceof HTMLInputElement && [
3087
3934
  "checkbox",
3088
3935
  "radio",
3089
3936
  "file",
@@ -3102,13 +3949,14 @@ var SessionRecorder = class {
3102
3949
  this.fieldTimes.delete(el);
3103
3950
  if (!this.fieldTimers.delete(el) || !isField(el)) return;
3104
3951
  const value = "value" in el ? String(el.value) : "";
3105
- const secret = el instanceof HTMLInputElement && el.type === "password" || SECRET.test(`${el.getAttribute("name") ?? ""} ${el.id} ${el.getAttribute("autocomplete") ?? ""}`);
3952
+ const secret = isSecretField(el);
3106
3953
  const target = this.target(el);
3107
3954
  this.recordLater({
3108
3955
  type: "input",
3109
3956
  el: target,
3110
- value: secret ? "•".repeat(Math.min(value.length, 12)) : collapse(value, 500),
3111
- masked: secret
3957
+ value: secret ? dots(value) : collapse(value, 500),
3958
+ masked: secret,
3959
+ ...secret ? { secret: secretNameOf(el) } : {}
3112
3960
  }, typed, [[target, el]]);
3113
3961
  }
3114
3962
  flushFields() {
@@ -3128,12 +3976,14 @@ var SessionRecorder = class {
3128
3976
  "time"
3129
3977
  ].includes(el.type)) {
3130
3978
  const target = this.target(el);
3131
- const value = el instanceof HTMLSelectElement ? el.selectedOptions[0]?.text ?? el.value : el.type === "file" ? Array.from(el.files ?? []).map((f) => f.name).join(", ") : el.value;
3979
+ const options = el instanceof HTMLSelectElement ? Array.from(el.selectedOptions, (o) => collapse(o.label || o.text, 80)) : void 0;
3980
+ const value = el instanceof HTMLSelectElement ? options[0] ?? el.value : el.type === "file" ? Array.from(el.files ?? []).map((f) => f.name).join(", ") : el.value;
3132
3981
  this.recordLater({
3133
3982
  type: "change",
3134
3983
  el: target,
3135
3984
  value: collapse(value, 200),
3136
- checked: el instanceof HTMLInputElement && (el.type === "checkbox" || el.type === "radio") ? el.checked : void 0
3985
+ checked: el instanceof HTMLInputElement && (el.type === "checkbox" || el.type === "radio") ? el.checked : void 0,
3986
+ ...options && options.length > 1 ? { options } : {}
3137
3987
  }, eventTime(e), [[target, el]]);
3138
3988
  }
3139
3989
  };
@@ -3178,6 +4028,7 @@ var SessionRecorder = class {
3178
4028
  };
3179
4029
  onFocus = (e) => {
3180
4030
  if (!this.active || !(e.target instanceof Element) || !isField(e.target) || ours(e.target)) return;
4031
+ isSecretField(e.target);
3181
4032
  const target = this.target(e.target);
3182
4033
  this.recordLater({
3183
4034
  type: "focus",
@@ -3257,12 +4108,14 @@ var SessionRecorder = class {
3257
4108
  if (!this.active || location.href === this.lastNavUrl) return;
3258
4109
  this.lastNavUrl = location.href;
3259
4110
  this.flushFields();
3260
- this.record({
4111
+ const at = now();
4112
+ const url = location.href;
4113
+ setTimeout(() => this.active && this.record({
3261
4114
  type: "nav",
3262
- url: location.href,
4115
+ url,
3263
4116
  title: document.title,
3264
4117
  how
3265
- }, now(), true);
4118
+ }, at, true), 120);
3266
4119
  setTimeout(() => this.snapshot("navigation"), 700);
3267
4120
  }
3268
4121
  onError = (e) => {
@@ -3880,6 +4733,8 @@ var MicStream = class {
3880
4733
  };
3881
4734
  //#endregion
3882
4735
  //#region src/client/screen.ts
4736
+ /** How long starting waits for the shared picture's first frame. */
4737
+ var FIRST_FRAME_WAIT_MS = 1500;
3883
4738
  function videoMime() {
3884
4739
  for (const m of [
3885
4740
  "video/webm;codecs=vp9",
@@ -3941,8 +4796,8 @@ var ScreenCapture = class {
3941
4796
  video.muted = true;
3942
4797
  video.playsInline = true;
3943
4798
  video.srcObject = stream;
3944
- await video.play().catch(() => void 0);
3945
4799
  this.video = video;
4800
+ await Promise.race([video.play().catch(() => void 0), new Promise((resolve) => setTimeout(resolve, FIRST_FRAME_WAIT_MS))]);
3946
4801
  }
3947
4802
  /** Start the video (when `video`): the session has begun. */
3948
4803
  record(video) {
@@ -3966,7 +4821,11 @@ var ScreenCapture = class {
3966
4821
  /** Take a screenshot now (at most one every 2.5 s, and 150 per session, unless forced). */
3967
4822
  shoot(reason, force = false) {
3968
4823
  const video = this.video;
3969
- if (!video || !this.active || !video.videoWidth) return;
4824
+ if (!video || !this.active) return;
4825
+ if (!video.videoWidth) {
4826
+ if (force) video.addEventListener("loadeddata", () => this.video === video && this.shoot(reason, true), { once: true });
4827
+ return;
4828
+ }
3970
4829
  const now = Date.now();
3971
4830
  if (!force && (now - this.lastShot < 2500 || this.shotCount >= 150)) return;
3972
4831
  this.lastShot = now;
@@ -4465,7 +5324,7 @@ var SessionClient = class {
4465
5324
  status: "failed",
4466
5325
  countdown: null,
4467
5326
  mic: "off",
4468
- error: "The studio isn’t reachable. Is `npm run dev` running?"
5327
+ error: "The studio isn’t reachable. Is it still running? Start it again with `npx @saywright/studio`."
4469
5328
  });
4470
5329
  throw new Error(this.state.error);
4471
5330
  }
@@ -4758,6 +5617,8 @@ var SessionClient = class {
4758
5617
  * Alt+Shift+R start / stop Alt+Shift+M mark this moment Alt+Shift+D show / hide
4759
5618
  */
4760
5619
  var HIDE_KEY = "saywright.widget.hidden";
5620
+ /** Which side of the page it sits on: moved out of the way of the page's own controls. */
5621
+ var SIDE_KEY = "saywright.widget.side";
4761
5622
  var PREFS_KEY = "saywright.widget.prefs";
4762
5623
  var CSS$1 = `
4763
5624
  :host{all:initial}
@@ -4795,6 +5656,10 @@ input[type=text]:focus{border-color:#0090ff}
4795
5656
  .voice[data-tone=warn] .bars i{background:#ffc53d}.voice[data-tone=warn] .voice-text{color:#ffc53d}
4796
5657
  .voice[data-tone=bad] .voice-text{color:#ff9592}
4797
5658
  .count{font-size:11.5px;color:#a0a0ab;display:flex;gap:8px}.count b{color:#ededef}.count .bad b{color:#ff9592}
5659
+ .root[data-side=left]{right:auto;left:16px}
5660
+ .root[data-side=left] .caption{margin:0 auto 8px 0}
5661
+ .mini{display:flex;align-items:center;justify-content:center;width:22px;height:22px;border-radius:50%;border:1px solid rgba(255,255,255,.16);background:rgba(22,22,26,.85);box-shadow:0 4px 14px rgba(0,0,0,.35);cursor:pointer;padding:0}
5662
+ .mini .dot{width:8px;height:8px;box-shadow:0 0 0 2px rgba(229,72,77,.25)}
4798
5663
  .caption{max-width:340px;margin:0 0 8px auto;padding:8px 12px;border-radius:12px;background:rgba(22,22,26,.9);border:1px solid rgba(255,255,255,.1);font-size:12.5px;box-shadow:0 8px 24px rgba(0,0,0,.3)}
4799
5664
  .caption .issue{color:#ff9592}
4800
5665
  .err{color:#ff9592;font-size:12px;margin-top:8px}
@@ -4820,7 +5685,9 @@ var ICON = {
4820
5685
  stop: "<svg width=\"12\" height=\"12\" viewBox=\"0 0 24 24\" fill=\"currentColor\"><rect x=\"5\" y=\"5\" width=\"14\" height=\"14\" rx=\"2\"/></svg>",
4821
5686
  pause: "<svg width=\"13\" height=\"13\" viewBox=\"0 0 24 24\" fill=\"currentColor\"><rect x=\"6\" y=\"4\" width=\"4\" height=\"16\" rx=\"1\"/><rect x=\"14\" y=\"4\" width=\"4\" height=\"16\" rx=\"1\"/></svg>",
4822
5687
  play: "<svg width=\"13\" height=\"13\" viewBox=\"0 0 24 24\" fill=\"currentColor\"><path d=\"M7 4v16l13-8z\"/></svg>",
4823
- close: "<svg width=\"13\" height=\"13\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.2\" stroke-linecap=\"round\"><path d=\"M6 6l12 12M18 6L6 18\"/></svg>"
5688
+ close: "<svg width=\"13\" height=\"13\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.2\" stroke-linecap=\"round\"><path d=\"M6 6l12 12M18 6L6 18\"/></svg>",
5689
+ side: "<svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M7 7h12l-3-3M17 17H5l3 3\"/></svg>",
5690
+ hide: "<svg width=\"13\" height=\"13\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.2\" stroke-linecap=\"round\"><path d=\"M6 12h12\"/></svg>"
4824
5691
  };
4825
5692
  function esc(s) {
4826
5693
  return s.replace(/[&<>"]/g, (c) => ({
@@ -4865,10 +5732,12 @@ var Widget = class {
4865
5732
  reachable = null;
4866
5733
  tick = null;
4867
5734
  hidden;
5735
+ side;
4868
5736
  constructor(endpoint, recorder) {
4869
5737
  this.recorder = recorder;
4870
5738
  this.endpoint = endpoint.replace(/\/$/, "");
4871
5739
  this.hidden = localStorage.getItem(HIDE_KEY) === "1";
5740
+ this.side = localStorage.getItem(SIDE_KEY) === "left" ? "left" : "right";
4872
5741
  this.host = document.createElement("saywright-recorder");
4873
5742
  this.shadow = this.host.attachShadow({ mode: "open" });
4874
5743
  const style = document.createElement("style");
@@ -4914,12 +5783,10 @@ var Widget = class {
4914
5783
  else this.start();
4915
5784
  } else if (e.code === "KeyM") {
4916
5785
  e.preventDefault();
4917
- this.mark();
5786
+ this.mark("shortcut");
4918
5787
  } else if (e.code === "KeyD") {
4919
5788
  e.preventDefault();
4920
- this.hidden = !this.hidden;
4921
- localStorage.setItem(HIDE_KEY, this.hidden ? "1" : "0");
4922
- this.render();
5789
+ this.setHidden(!this.hidden);
4923
5790
  }
4924
5791
  };
4925
5792
  onState(s) {
@@ -4930,6 +5797,13 @@ var Widget = class {
4930
5797
  }
4931
5798
  this.render();
4932
5799
  }
5800
+ /** Hidden: out of the page's way. While recording, a small dot stays, so it is never recording unseen. */
5801
+ setHidden(on) {
5802
+ this.hidden = on;
5803
+ localStorage.setItem(HIDE_KEY, on ? "1" : "0");
5804
+ this.shown = "";
5805
+ this.render();
5806
+ }
4933
5807
  savePrefs() {
4934
5808
  localStorage.setItem(PREFS_KEY, JSON.stringify(this.prefs));
4935
5809
  }
@@ -4942,7 +5816,7 @@ var Widget = class {
4942
5816
  endpoint: this.endpoint,
4943
5817
  name: this.name || void 0,
4944
5818
  lang: this.prefs.lang,
4945
- appUrl: location.href,
5819
+ appUrl: redactUrl(location.href),
4946
5820
  source: "app",
4947
5821
  mic: this.prefs.mic,
4948
5822
  screen: this.prefs.screen,
@@ -4966,15 +5840,27 @@ var Widget = class {
4966
5840
  this.recorder.stop();
4967
5841
  await this.client.stop();
4968
5842
  }
4969
- mark() {
5843
+ /** One mark: the app's recorder logs it, the recording client adds the picture (it was logged twice, once by each). */
5844
+ mark(via = "button") {
4970
5845
  if (this.client.state.status !== "recording") return;
4971
- this.recorder.mark("", "shortcut");
4972
- this.client.mark("", "app");
5846
+ this.recorder.mark("", via);
5847
+ this.client.moment("mark", true);
4973
5848
  }
4974
5849
  render() {
4975
5850
  const s = this.client.state;
4976
- this.root.style.display = this.hidden && s.status !== "recording" && s.status !== "starting" ? "none" : "";
5851
+ const live = s.status === "recording" || s.status === "starting" || s.status === "stopping";
5852
+ this.root.style.display = this.hidden && !live ? "none" : "";
5853
+ this.root.dataset.side = this.side;
4977
5854
  if (s.status === "starting" && s.countdown && !s.id) return this.renderCountdown(s.countdown);
5855
+ if (live && this.hidden) {
5856
+ this.issueUntil = 0;
5857
+ const html = `<button class="mini" data-act="show" title="Recording: show the controls (Alt+Shift+D)" aria-label="Recording: show the controls"><span class="dot ${s.paused ? "" : "rec"}"></span></button>`;
5858
+ if (html === this.shown) return;
5859
+ this.shown = html;
5860
+ this.root.innerHTML = html;
5861
+ this.bind();
5862
+ return;
5863
+ }
4978
5864
  if (s.status === "recording" || s.status === "starting" || s.status === "stopping") return this.renderRecording(s);
4979
5865
  this.shown = "";
4980
5866
  if (s.status === "processing" || s.status === "ready" || s.status === "failed" && s.id) return this.renderDone(s);
@@ -4998,7 +5884,7 @@ var Widget = class {
4998
5884
  <div class="check"><span>Record the screen</span><button class="switch" role="switch" data-toggle="screen" aria-checked="${p.screen}"></button></div>
4999
5885
  <div class="check"><span>Count down 3 s first</span><button class="switch" role="switch" data-toggle="countdown" aria-checked="${p.countdown}"></button></div>
5000
5886
  </div>
5001
- ${this.reachable === false ? `<div class="err">The studio isn’t running at ${esc(this.endpoint)}. Start it with <b>npm run dev</b>.</div>` : ""}
5887
+ ${this.reachable === false ? `<div class="err">The studio isn’t running at ${esc(this.endpoint)}. Start it with <b>npx @saywright/studio</b>.</div>` : ""}
5002
5888
  ${s.error ? `<div class="err">${esc(s.error)}${s.busy ? ` <button class="btn" style="height:24px;margin-top:6px" data-act="replace">Stop it and start mine</button>` : ""}</div>` : ""}
5003
5889
  <div class="row" style="margin-top:12px"><button class="btn primary" style="flex:1" data-act="start"><span class="dot" style="background:#fff;box-shadow:none;width:8px;height:8px"></span>Start recording</button></div>
5004
5890
  <div class="muted" style="margin-top:8px;font-size:11.5px">Use the app as usual and talk about what you notice. <span class="kbd">Alt⇧M</span> marks a moment, <span class="kbd">Alt⇧R</span> stops.</div>
@@ -5024,6 +5910,8 @@ var Widget = class {
5024
5910
  <button class="btn icon" data-act="mark" title="Mark this moment (Alt+Shift+M)">${ICON.flag}</button>
5025
5911
  <button class="btn icon" data-act="pause" title="${s.paused ? "Resume narration" : "Pause narration"}">${s.paused ? ICON.play : ICON.pause}</button>
5026
5912
  <button class="btn" data-act="stop" ${s.status !== "recording" ? "disabled" : ""}>${ICON.stop}${s.status === "stopping" ? "Saving…" : "Stop"}</button>
5913
+ <button class="btn icon" data-act="side" title="Move to the other side of the page" aria-label="Move to the other side of the page">${ICON.side}</button>
5914
+ <button class="btn icon" data-act="hide" title="Hide the controls; it goes on recording (Alt+Shift+D)" aria-label="Hide the controls">${ICON.hide}</button>
5027
5915
  </div>`;
5028
5916
  this.issueUntil = issue ? issue.t + 6e3 : 0;
5029
5917
  if (html === this.shown) return;
@@ -5095,7 +5983,7 @@ var Widget = class {
5095
5983
  renderLive() {
5096
5984
  const s = this.client.state;
5097
5985
  if (s.status !== "recording") return;
5098
- if (this.issueUntil && Date.now() > this.issueUntil) return this.renderRecording(s);
5986
+ if (this.issueUntil && Date.now() > this.issueUntil) return this.render();
5099
5987
  const t = clock(Date.now() - s.startedAt);
5100
5988
  if (t !== this.shownTime) {
5101
5989
  const time = this.shadow.querySelector("[data-time]");
@@ -5159,7 +6047,13 @@ var Widget = class {
5159
6047
  else if (act === "mark") this.mark();
5160
6048
  else if (act === "pause") this.client.setPaused(!this.client.state.paused);
5161
6049
  else if (act === "screen") this.client.resumeScreen({ video: true });
5162
- else if (act === "dismiss" || act === "opened") {
6050
+ else if (act === "hide") this.setHidden(true);
6051
+ else if (act === "show") this.setHidden(false);
6052
+ else if (act === "side") {
6053
+ this.side = this.side === "right" ? "left" : "right";
6054
+ localStorage.setItem(SIDE_KEY, this.side);
6055
+ this.render();
6056
+ } else if (act === "dismiss" || act === "opened") {
5163
6057
  this.client.reset();
5164
6058
  this.view = "button";
5165
6059
  this.render();
@@ -5187,6 +6081,18 @@ var Widget = class {
5187
6081
  };
5188
6082
  //#endregion
5189
6083
  //#region src/bridge/experts.ts
6084
+ /**
6085
+ * Checks a page against the rules the studio's experts stand on: Google
6086
+ * Search Central for SEO, WCAG 2.2 (through axe-core) for accessibility,
6087
+ * GOV.UK, web.dev and W3C guidance for content, forms and right-to-left text.
6088
+ * Only what a rule backs is reported, with the elements it is about; judging
6089
+ * quality (is this title good?) is left to the AI review.
6090
+ *
6091
+ * Runs only when asked, inside the page, in short steps with a pause between
6092
+ * them: the page and the studio (same thread) stay responsive, and the studio
6093
+ * shows how far it has got. Long runs of like items (rows, cards) are checked
6094
+ * by their first few.
6095
+ */
5190
6096
  var G = "https://developers.google.com/search/docs";
5191
6097
  /** Lighthouse's link-text list (English), and the Arabic equivalents. */
5192
6098
  var VAGUE_LINKS = /* @__PURE__ */ new Set([
@@ -5314,7 +6220,7 @@ function watchVitals() {
5314
6220
  } catch {}
5315
6221
  }
5316
6222
  function facts() {
5317
- const text = ((document.querySelector("main, [role=\"main\"]") ?? document.body).innerText ?? "").replace(/\n{3,}/g, "\n\n").trim();
6223
+ const text = maskSecrets(((document.querySelector("main, [role=\"main\"]") ?? document.body).innerText ?? "").replace(/\n{3,}/g, "\n\n").trim());
5318
6224
  const here = location.origin;
5319
6225
  let internal = 0;
5320
6226
  let external = 0;
@@ -5336,7 +6242,7 @@ function facts() {
5336
6242
  }
5337
6243
  const fields = Array.from(document.querySelectorAll("input:not([type=hidden]):not([type=submit]):not([type=button]), select, textarea")).filter(shown);
5338
6244
  return {
5339
- url: location.href,
6245
+ url: redactUrl(location.href),
5340
6246
  title: document.title,
5341
6247
  description: meta("description"),
5342
6248
  canonical: document.querySelector("link[rel=\"canonical\"]")?.getAttribute("href") ?? null,
@@ -5406,7 +6312,7 @@ function linkTargets() {
5406
6312
  for (const a of Array.from(document.querySelectorAll("a[href]")).filter(shown)) {
5407
6313
  const link = {
5408
6314
  text: collapse(a.innerText || a.getAttribute("aria-label") || "", 40),
5409
- href: (a.getAttribute("href") ?? "").slice(0, 120)
6315
+ href: redactUrl(a.getAttribute("href") ?? "").slice(0, 120)
5410
6316
  };
5411
6317
  if (!out.some((l) => l.text === link.text && l.href === link.href)) out.push(link);
5412
6318
  if (out.length >= 25) break;
@@ -6352,10 +7258,17 @@ function start() {
6352
7258
  return;
6353
7259
  case "locator": {
6354
7260
  const el = elOf(m.id);
6355
- return el ? reply(m.reqId, {
7261
+ if (!el) return fail(m.reqId, "gone");
7262
+ if (!m.check) return reply(m.reqId, {
6356
7263
  locator: buildLocator(el),
6357
7264
  info: describe(el)
6358
- }) : fail(m.reqId, "gone");
7265
+ });
7266
+ const target = m.check.state ? controlOf(el) : el;
7267
+ return reply(m.reqId, {
7268
+ locator: buildLocator(target, m.check.state ? {} : { check: m.check.expected }),
7269
+ info: describe(target),
7270
+ ...m.check.state ? stateOf(el, target) : {}
7271
+ });
6359
7272
  }
6360
7273
  case "step": {
6361
7274
  const el = elOf(m.id);
@@ -6416,6 +7329,9 @@ function start() {
6416
7329
  case "record":
6417
7330
  recorder.setOn(m.on);
6418
7331
  return;
7332
+ case "flush":
7333
+ recorder.flushAll();
7334
+ return reply(m.reqId, {});
6419
7335
  case "mouse":
6420
7336
  mouseOn = m.on;
6421
7337
  return;
@@ -6461,7 +7377,7 @@ function start() {
6461
7377
  v: 1,
6462
7378
  oldest: 1
6463
7379
  });
6464
- if (side && side !== mismatch) console.warn(side === "plugin" ? "[saywright] This app’s Saywright plugin is older than the studio: run npm i -D @saywright/vite@latest and restart the dev server." : "[saywright] The Saywright studio is older than this app’s plugin: update Saywright (npx saywright@latest).");
7380
+ if (side && side !== mismatch) console.warn(side === "plugin" ? "[saywright] This app’s Saywright plugin is older than the studio: run npm i -D @saywright/vite@latest and restart the dev server." : "[saywright] The Saywright studio is older than this app’s plugin: update Saywright (npx @saywright/studio@latest).");
6465
7381
  mismatch = side;
6466
7382
  post({
6467
7383
  type: "ready",
@@ -6487,21 +7403,35 @@ function start() {
6487
7403
  });
6488
7404
  let mouseOn = false;
6489
7405
  let lastMouse = 0;
6490
- window.addEventListener("pointermove", (e) => {
6491
- if (!mouseOn) return;
6492
- const now = performance.now();
6493
- if (now - lastMouse < 16) return;
6494
- lastMouse = now;
7406
+ let pending = null;
7407
+ let later = null;
7408
+ const sendMouse = () => {
7409
+ later = null;
7410
+ if (!pending || !mouseOn) return;
7411
+ lastMouse = performance.now();
6495
7412
  post({
6496
7413
  type: "mouse",
7414
+ ...pending
7415
+ });
7416
+ pending = null;
7417
+ };
7418
+ window.addEventListener("pointermove", (e) => {
7419
+ if (!mouseOn) return;
7420
+ pending = {
6497
7421
  x: e.clientX,
6498
7422
  y: e.clientY
6499
- });
7423
+ };
7424
+ const wait = 16 - (performance.now() - lastMouse);
7425
+ if (wait <= 0) sendMouse();
7426
+ else later ??= setTimeout(sendMouse, wait);
6500
7427
  }, {
6501
7428
  passive: true,
6502
7429
  capture: true
6503
7430
  });
6504
7431
  document.documentElement.addEventListener("pointerleave", () => {
7432
+ pending = null;
7433
+ if (later) clearTimeout(later);
7434
+ later = null;
6505
7435
  if (mouseOn) post({
6506
7436
  type: "mouse",
6507
7437
  x: null,