@saywright/studio 0.1.1 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/dist/THIRD_PARTY_NOTICES.md +0 -6
  2. package/dist/bridge.js +1188 -210
  3. package/dist/cli.mjs +9464 -7528
  4. package/dist/client/assets/CommandPalette-BM1BSUxc.js +1 -0
  5. package/dist/client/assets/CommandPalette-BM1BSUxc.js.br +0 -0
  6. package/dist/client/assets/CommandPalette-BM1BSUxc.js.gz +0 -0
  7. package/dist/client/assets/HelpDialog-Bc6yjiVi.js +1 -0
  8. package/dist/client/assets/HelpDialog-Bc6yjiVi.js.br +0 -0
  9. package/dist/client/assets/HelpDialog-Bc6yjiVi.js.gz +0 -0
  10. package/dist/client/assets/ReportView-DKcggbSt.js +4 -0
  11. package/dist/client/assets/ReportView-DKcggbSt.js.br +0 -0
  12. package/dist/client/assets/ReportView-DKcggbSt.js.gz +0 -0
  13. package/dist/client/assets/SettingsDialog-CIpvauuY.js +1 -0
  14. package/dist/client/assets/SettingsDialog-CIpvauuY.js.br +0 -0
  15. package/dist/client/assets/SettingsDialog-CIpvauuY.js.gz +0 -0
  16. package/dist/client/assets/arrow-left-5LUh_bW3.js +1 -0
  17. package/dist/client/assets/brand-BcIB43Uo.js +57 -0
  18. package/dist/client/assets/brand-BcIB43Uo.js.br +0 -0
  19. package/dist/client/assets/brand-BcIB43Uo.js.gz +0 -0
  20. package/dist/client/assets/footprints-CfMi7MiN.js +1 -0
  21. package/dist/client/assets/index-BrM0s_jp.css +2 -0
  22. package/dist/client/assets/index-BrM0s_jp.css.br +0 -0
  23. package/dist/client/assets/index-BrM0s_jp.css.gz +0 -0
  24. package/dist/client/assets/index-D1ooswLS.js +19 -0
  25. package/dist/client/assets/index-D1ooswLS.js.br +0 -0
  26. package/dist/client/assets/index-D1ooswLS.js.gz +0 -0
  27. package/dist/client/assets/refresh-cw-BM1RSPTL.js +1 -0
  28. package/dist/client/index.html +3 -5
  29. package/dist/client/index.html.br +0 -0
  30. package/dist/client/index.html.gz +0 -0
  31. package/dist/sample/package.json +1 -1
  32. package/dist/sample/src/App.tsx +3 -2
  33. package/dist/sample/src/lib/route.ts +3 -2
  34. package/dist/sample/src/pages/Account.tsx +88 -0
  35. package/dist/sample/src/styles.css +5 -0
  36. package/package.json +1 -2
  37. package/dist/client/assets/Calibration-xY4YDLzU.js +0 -1
  38. package/dist/client/assets/Calibration-xY4YDLzU.js.br +0 -0
  39. package/dist/client/assets/Calibration-xY4YDLzU.js.gz +0 -0
  40. package/dist/client/assets/CommandPalette-BA3akF-p.js +0 -1
  41. package/dist/client/assets/CommandPalette-BA3akF-p.js.br +0 -0
  42. package/dist/client/assets/CommandPalette-BA3akF-p.js.gz +0 -0
  43. package/dist/client/assets/HelpDialog-C6qmdEwi.js +0 -1
  44. package/dist/client/assets/HelpDialog-C6qmdEwi.js.br +0 -0
  45. package/dist/client/assets/HelpDialog-C6qmdEwi.js.gz +0 -0
  46. package/dist/client/assets/ReportView-Bj9xxLCi.js +0 -4
  47. package/dist/client/assets/ReportView-Bj9xxLCi.js.br +0 -0
  48. package/dist/client/assets/ReportView-Bj9xxLCi.js.gz +0 -0
  49. package/dist/client/assets/SettingsDialog-VrACcdUX.js +0 -1
  50. package/dist/client/assets/SettingsDialog-VrACcdUX.js.br +0 -0
  51. package/dist/client/assets/SettingsDialog-VrACcdUX.js.gz +0 -0
  52. package/dist/client/assets/button-B2NFEpCs.js +0 -18
  53. package/dist/client/assets/button-B2NFEpCs.js.br +0 -0
  54. package/dist/client/assets/button-B2NFEpCs.js.gz +0 -0
  55. package/dist/client/assets/camera-BD9K1f5I.js +0 -1
  56. package/dist/client/assets/confirm-D1U-bFIL.js +0 -41
  57. package/dist/client/assets/confirm-D1U-bFIL.js.br +0 -0
  58. package/dist/client/assets/confirm-D1U-bFIL.js.gz +0 -0
  59. package/dist/client/assets/footprints-DiYtIryA.js +0 -1
  60. package/dist/client/assets/index-BakNwOFF.css +0 -2
  61. package/dist/client/assets/index-BakNwOFF.css.br +0 -0
  62. package/dist/client/assets/index-BakNwOFF.css.gz +0 -0
  63. package/dist/client/assets/index-DUAtV_Av.js +0 -18
  64. package/dist/client/assets/index-DUAtV_Av.js.br +0 -0
  65. package/dist/client/assets/index-DUAtV_Av.js.gz +0 -0
  66. package/dist/client/assets/preload-helper-BLdHbfdN.js +0 -1
  67. package/dist/client/assets/preload-helper-BLdHbfdN.js.br +0 -0
  68. package/dist/client/assets/preload-helper-BLdHbfdN.js.gz +0 -0
  69. package/dist/client/assets/refresh-cw-Dobx2YmO.js +0 -1
  70. package/dist/client/assets/tracker.worker-DWwj76VA.js +0 -41
  71. package/dist/client/assets/tracker.worker-DWwj76VA.js.br +0 -0
  72. package/dist/client/assets/tracker.worker-DWwj76VA.js.gz +0 -0
  73. package/dist/client/assets/vision_bundle-ByYCzljp.js +0 -41
  74. package/dist/client/assets/vision_bundle-ByYCzljp.js.br +0 -0
  75. package/dist/client/assets/vision_bundle-ByYCzljp.js.gz +0 -0
package/dist/bridge.js CHANGED
@@ -1,9 +1,326 @@
1
- //#region ../../packages/vite/src/protocol.ts
1
+ //#region ../../packages/vite/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 ../../packages/vite/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,205 @@ 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);
1024
+ if (el instanceof HTMLSelectElement) return collapse(Array.from(el.selectedOptions, (o) => o.label || o.text).join(", "), 80);
563
1025
  return collapse(el.innerText ?? el.textContent, 80);
564
1026
  }
565
1027
  function shortLabel(el) {
566
1028
  const aria = el.getAttribute("aria-label");
567
1029
  if (aria) return collapse(aria, 28);
568
- if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) return collapse(labelText(el) || el.placeholder || el.value, 28);
1030
+ if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) return collapse(labelText(el) || el.placeholder || (isSecretField(el) ? "" : el.value), 28);
569
1031
  return collapse(el.innerText ?? el.textContent, 28);
570
1032
  }
571
- /** Index of `el` among nodes rendered by the same JSX site (same data-ds-src). */
1033
+ /** Index of `el` among nodes rendered by the same JSX site (same data-ds-src), and how many there are. */
572
1034
  function srcIndex(el, src) {
573
- if (!src) return 0;
1035
+ if (!src) return {
1036
+ index: 0,
1037
+ same: 1
1038
+ };
574
1039
  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;
1040
+ for (let i = 0; i < all.length; i++) if (all[i] === el) return {
1041
+ index: i,
1042
+ same: all.length
1043
+ };
1044
+ return {
1045
+ index: 0,
1046
+ same: all.length
1047
+ };
577
1048
  }
578
1049
  function describe(el) {
579
1050
  const annotated = el.getAttribute(SRC_ATTR);
@@ -595,8 +1066,11 @@ function describe(el) {
595
1066
  text: visibleText(el),
596
1067
  role: roleOf(el),
597
1068
  rect: rectOf(el),
598
- index: srcIndex(el, src)
1069
+ index: 0
599
1070
  };
1071
+ const at = srcIndex(el, annotated);
1072
+ info.index = at.index;
1073
+ if (at.same > 1) info.same = at.same;
600
1074
  if (frames.length) info.frames = frames;
601
1075
  if (!src) info.hint = sourceHint(el);
602
1076
  return info;
@@ -723,6 +1197,48 @@ function plainElement(el) {
723
1197
  return cur;
724
1198
  }
725
1199
  //#endregion
1200
+ //#region ../../packages/vite/src/bridge/own.ts
1201
+ /**
1202
+ * Saywright's own work on the app's page (reading the screen, building
1203
+ * locators, answering the studio): timed, so a slow moment it caused is never
1204
+ * reported as the app's.
1205
+ */
1206
+ var spans = [];
1207
+ /** Run some of our own work, remembering when it ran if it took long enough to matter. */
1208
+ function own(fn) {
1209
+ const start = performance.now();
1210
+ try {
1211
+ return fn();
1212
+ } finally {
1213
+ const end = performance.now();
1214
+ if (end - start >= 8) {
1215
+ spans.push([start, end]);
1216
+ if (spans.length > 64) spans.splice(0, spans.length - 64);
1217
+ }
1218
+ }
1219
+ }
1220
+ /** How much of [start, end] (performance.now() times) was our own work. */
1221
+ function ownTime(start, end) {
1222
+ let ms = 0;
1223
+ for (const [a, b] of spans) ms += Math.max(0, Math.min(b, end) - Math.max(a, start));
1224
+ return ms;
1225
+ }
1226
+ /** When a replay or Autopilot last acted on the page (a click, typing, a key): the times of the last few. */
1227
+ var acted = [];
1228
+ /** A step a replay or Autopilot did, now. */
1229
+ function scriptedInput() {
1230
+ acted.push(performance.now());
1231
+ if (acted.length > 16) acted.shift();
1232
+ }
1233
+ /**
1234
+ * Whether a replay or Autopilot acted in the half second before `at` (a performance.now() time): as the browser
1235
+ * leaves out what moves right after a person's input (a menu opening where it was clicked), what moves right after a
1236
+ * scripted step is that step's doing, not the page shifting under the reader.
1237
+ */
1238
+ function scriptedBefore(at, ms = 500) {
1239
+ return acted.some((t) => t <= at && at - t <= ms);
1240
+ }
1241
+ //#endregion
726
1242
  //#region ../../packages/vite/src/bridge/actions.ts
727
1243
  var CLICKABLE$2 = "button, a[href], [role=\"button\"], [role=\"link\"], [role=\"tab\"], [role=\"menuitem\"], [role=\"checkbox\"], [role=\"radio\"], input, select, textarea, label, summary, [tabindex]";
728
1244
  /** The element a click at (x, y) should activate. */
@@ -735,6 +1251,7 @@ var lastField = null;
735
1251
  function clickAt(x, y) {
736
1252
  const el = clickTargetAt(x, y);
737
1253
  if (!el) return null;
1254
+ scriptedInput();
738
1255
  if (el instanceof HTMLElement) {
739
1256
  if (isField(el) && !(el instanceof HTMLSelectElement) && ![
740
1257
  "checkbox",
@@ -762,6 +1279,7 @@ function setNativeValue(el, value) {
762
1279
  el.dispatchEvent(new Event("change", { bubbles: true }));
763
1280
  }
764
1281
  function typeText(text, submit) {
1282
+ scriptedInput();
765
1283
  const active = document.activeElement;
766
1284
  const target = active instanceof HTMLInputElement || active instanceof HTMLTextAreaElement ? active : lastField && lastField.isConnected ? lastField : null;
767
1285
  if (!target) return null;
@@ -810,14 +1328,47 @@ function scrollAt(x, y, dx, dy) {
810
1328
  function looksVolatile(s) {
811
1329
  return /^[\s$€£¥+\-−–.,:%0-9()]*$/.test(s) || /\d/.test(s) && s.length <= 12;
812
1330
  }
1331
+ var squash = (s) => (s ?? "").replace(/\s+/g, "").toLowerCase();
1332
+ /** A number as a page shows it: 12, 1,234.50, 3.5. */
1333
+ var NUMBER = /\d[\d.,]*/g;
1334
+ var escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&");
1335
+ /**
1336
+ * A name or a text with numbers in it ("Pay $91.57 and subscribe", "Like this portfolio (1)") as a pattern with the
1337
+ * numbers left open, so a fix that changes a price or a count still finds it. Null without numbers, or when only
1338
+ * its numbers say what it is.
1339
+ */
1340
+ function numbersOpen(s) {
1341
+ if (!/\d/.test(s) || s.length > 60 || s.endsWith("…")) return null;
1342
+ const parts = s.split(NUMBER);
1343
+ if (parts.join("").replace(/[^\p{L}]+/gu, "").length < 3) return null;
1344
+ const source = `^${parts.map(escapeRe).join("\\d[\\d.,]*")}$`;
1345
+ const hint = parts.map(squash).sort((a, b) => b.length - a.length)[0] ?? "";
1346
+ return {
1347
+ re: new RegExp(source),
1348
+ code: `/${source}/`,
1349
+ hint
1350
+ };
1351
+ }
1352
+ var always = () => true;
1353
+ /** What the element will show: the expected value in place of the part of its text shaped like it, else that value alone. */
1354
+ function keeper(el, expected) {
1355
+ const now = visibleText(el);
1356
+ const shape = expected.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/\d+/g, "\\d+");
1357
+ const part = /\d/.test(expected) && shape !== expected ? new RegExp(shape).exec(now)?.[0] : void 0;
1358
+ const then = now.toLowerCase().includes(expected.toLowerCase()) ? now : part ? now.replace(part, () => expected) : expected;
1359
+ return (w) => typeof w === "string" ? then.toLowerCase().includes(w.toLowerCase()) : w.re.test(then);
1360
+ }
1361
+ /** Whether its name is its own text (a button's words), not given apart from it (a label, aria-label). */
1362
+ var namedByText = (el) => !el.hasAttribute("aria-label") && !el.hasAttribute("aria-labelledby") && !isField(el) && !(el instanceof HTMLImageElement);
813
1363
  var ROLE_SELECTORS = {
814
1364
  button: "button, [role=\"button\"], input[type=\"button\"], input[type=\"submit\"], input[type=\"reset\"]",
815
1365
  link: "a[href], [role=\"link\"]",
816
1366
  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\"]",
1367
+ textbox: "input:not([type]), input[type=\"text\"], input[type=\"email\"], input[type=\"url\"], input[type=\"tel\"], textarea, [role=\"textbox\"]",
818
1368
  checkbox: "input[type=\"checkbox\"], [role=\"checkbox\"]",
819
1369
  radio: "input[type=\"radio\"], [role=\"radio\"]",
820
1370
  combobox: "select, [role=\"combobox\"]",
1371
+ listbox: "select, [role=\"listbox\"]",
821
1372
  tab: "[role=\"tab\"]",
822
1373
  menuitem: "[role=\"menuitem\"]",
823
1374
  article: "article, [role=\"article\"]",
@@ -850,20 +1401,25 @@ function roleFor(el) {
850
1401
  default: return null;
851
1402
  }
852
1403
  }
853
- var squash = (s) => (s ?? "").replace(/\s+/g, "").toLowerCase();
1404
+ /** Playwright's text matching: case-insensitive and partial (spaces collapsed), or exact. */
1405
+ function sameWords(actual, wanted, exact) {
1406
+ const a = actual.replace(/\s+/g, " ").trim();
1407
+ const w = wanted.replace(/\s+/g, " ").trim();
1408
+ return exact ? a === w : a.toLowerCase().includes(w.toLowerCase());
1409
+ }
854
1410
  /** The elements of this role (with this name, when given) in `scope`, as getByRole matches them. */
855
1411
  function byRole(scope, role, name, exact, limit = Infinity) {
856
1412
  const sel = ROLE_SELECTORS[role] ?? `[role="${role}"]`;
857
- const needle = squash(name);
1413
+ const needle = name === null ? "" : typeof name === "string" ? squash(name) : name.hint;
858
1414
  const out = [];
859
1415
  const all = scope.querySelectorAll(sel);
860
1416
  for (let i = 0; i < all.length && out.length < limit; i++) {
861
1417
  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;
1418
+ 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
1419
  if (roleFor(el) !== role) continue;
864
1420
  if (name !== null) {
865
1421
  const an = accessibleName(el);
866
- if (!(exact ? an === name : an.toLowerCase().includes(name.toLowerCase()))) continue;
1422
+ if (!(typeof name !== "string" ? name.re.test(an) : exact ? an === name : an.toLowerCase().includes(name.toLowerCase()))) continue;
867
1423
  }
868
1424
  out.push(el);
869
1425
  }
@@ -875,7 +1431,7 @@ function byRole(scope, role, name, exact, limit = Infinity) {
875
1431
  */
876
1432
  var counted = /* @__PURE__ */ new Map();
877
1433
  function countByRoleAndName(role, name, exact) {
878
- const key = `${role}\n${exact ? 1 : 0}\n${name}`;
1434
+ const key = `${role}\n${exact ? 1 : 0}\n${typeof name === "string" ? name : name.code}`;
879
1435
  const now = performance.now();
880
1436
  const hit = counted.get(key);
881
1437
  if (hit && now - hit.at < 1e3) return hit.n;
@@ -887,13 +1443,15 @@ function countByRoleAndName(role, name, exact) {
887
1443
  });
888
1444
  return n;
889
1445
  }
890
- /** Innermost elements in `scope` whose own text is exactly `text`. */
1446
+ /** Innermost elements in `scope` whose own text is exactly `text` (or matches the pattern). */
891
1447
  function byText$1(scope, text) {
892
1448
  const out = [];
1449
+ const is = typeof text === "string" ? (t) => t === text : (t) => text.re.test(t);
893
1450
  const all = scope.querySelectorAll("*");
894
1451
  for (let i = 0; i < all.length; i++) {
895
1452
  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);
1453
+ if (typeof text !== "string" && !squash(el.textContent).includes(text.hint)) continue;
1454
+ if (is(collapse(el.innerText ?? "", 200)) && !Array.from(el.children).some((c) => is(collapse(c.innerText ?? "", 200)))) out.push(el);
897
1455
  }
898
1456
  return out;
899
1457
  }
@@ -906,6 +1464,7 @@ function describeRole(role, name) {
906
1464
  checkbox: "checkbox",
907
1465
  radio: "option",
908
1466
  combobox: "menu",
1467
+ listbox: "list",
909
1468
  tab: "tab"
910
1469
  }[role] ?? role}`;
911
1470
  }
@@ -914,7 +1473,22 @@ function namingClass(el) {
914
1473
  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
1474
  return null;
916
1475
  }
917
- function inners(el) {
1476
+ /** A tag as a person names what it holds. */
1477
+ var TAG_WORDS = {
1478
+ td: "cell",
1479
+ th: "heading",
1480
+ dd: "value",
1481
+ li: "item",
1482
+ span: "text",
1483
+ p: "text",
1484
+ strong: "text",
1485
+ b: "text",
1486
+ div: "text",
1487
+ output: "value",
1488
+ time: "time",
1489
+ img: "image"
1490
+ };
1491
+ function inners(el, keep) {
918
1492
  const out = [];
919
1493
  const testId = el.getAttribute("data-testid");
920
1494
  if (testId) out.push({
@@ -924,17 +1498,26 @@ function inners(el) {
924
1498
  });
925
1499
  const role = roleFor(el);
926
1500
  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 })`,
1501
+ const byName = !namedByText(el) || keep === always;
1502
+ if (role && name && name.length <= 60) {
1503
+ const open = numbersOpen(name);
1504
+ if (open && (byName || keep(open))) out.push({
1505
+ code: `getByRole(${jsString(role)}, { name: ${open.code} })`,
935
1506
  words: `“${name}”`,
936
- count: (s) => byRole(s, role, name, true, 2).length
1507
+ count: (s) => byRole(s, role, open, false, 2).length
937
1508
  });
1509
+ if (!looksVolatile(name) && (byName || keep(name))) {
1510
+ out.push({
1511
+ code: `getByRole(${jsString(role)}, { name: ${jsString(name)} })`,
1512
+ words: `“${name}”`,
1513
+ count: (s) => byRole(s, role, name, false, 2).length
1514
+ });
1515
+ out.push({
1516
+ code: `getByRole(${jsString(role)}, { name: ${jsString(name)}, exact: true })`,
1517
+ words: `“${name}”`,
1518
+ count: (s) => byRole(s, role, name, true, 2).length
1519
+ });
1520
+ }
938
1521
  }
939
1522
  if (isField(el)) {
940
1523
  const label = labelText(el);
@@ -951,11 +1534,19 @@ function inners(el) {
951
1534
  });
952
1535
  }
953
1536
  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
- });
1537
+ if (!isField(el) && text && text.length <= 40) {
1538
+ const open = numbersOpen(text);
1539
+ if (open && keep(open)) out.push({
1540
+ code: `getByText(${open.code})`,
1541
+ words: `“${text}”`,
1542
+ count: (s) => byText$1(s, open).length
1543
+ });
1544
+ if (!looksVolatile(text) && keep(text)) out.push({
1545
+ code: `getByText(${jsString(text)}, { exact: true })`,
1546
+ words: `“${text}”`,
1547
+ count: (s) => byText$1(s, text).length
1548
+ });
1549
+ }
959
1550
  const tag = el.tagName.toLowerCase();
960
1551
  const cls = namingClass(el);
961
1552
  if (cls) out.push({
@@ -963,6 +1554,11 @@ function inners(el) {
963
1554
  words: `the ${cls.replace(/[-_]/g, " ")}`,
964
1555
  count: (s) => s.querySelectorAll(`${tag}.${CSS.escape(cls)}`).length
965
1556
  });
1557
+ out.push({
1558
+ code: `locator(${jsString(tag)})`,
1559
+ words: `the ${TAG_WORDS[tag] ?? tag}`,
1560
+ count: (s) => s.querySelectorAll(tag).length
1561
+ });
966
1562
  return out;
967
1563
  }
968
1564
  /** A container, as a locator, and the elements that locator finds on the page. */
@@ -1010,12 +1606,12 @@ var SCOPE_LEVELS = 6;
1010
1606
  * 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
1607
  * its own title the same way.
1012
1608
  */
1013
- function scoped(el) {
1014
- const ways = inners(el);
1609
+ function scoped(el, keep) {
1610
+ const ways = inners(el, keep);
1015
1611
  const text = visibleText(el);
1016
1612
  const own = named(roleFor(el)) ? null : boxOf(el);
1017
1613
  const ownTitle = own && titleOf(el, null);
1018
- if (own && ownTitle && own.all().filter((c) => matchesText(c, ownTitle)).length === 1) return {
1614
+ if (own && ownTitle && keep(ownTitle) && own.all().filter((c) => matchesText(c, ownTitle)).length === 1) return {
1019
1615
  code: `${own.code}.filter({ hasText: ${jsString(ownTitle)} })`,
1020
1616
  description: `“${ownTitle}”`,
1021
1617
  text,
@@ -1050,10 +1646,10 @@ var ordinal = (n) => `${n}${n % 100 >= 11 && n % 100 <= 13 ? "th" : [
1050
1646
  "rd"
1051
1647
  ][n % 10] ?? "th"}`;
1052
1648
  /** The last resort: which of the same it is, counting from the top of the page. */
1053
- function nth(el) {
1649
+ function nth(el, keep) {
1054
1650
  const text = visibleText(el);
1055
1651
  const role = roleFor(el);
1056
- const name = named(role) ? accessibleName(el) : "";
1652
+ const name = named(role) && (!namedByText(el) || keep(accessibleName(el))) ? accessibleName(el) : "";
1057
1653
  const tag = el.tagName.toLowerCase();
1058
1654
  const cls = namingClass(el);
1059
1655
  const pick = (code, all, words) => {
@@ -1101,6 +1697,8 @@ function locatorFromInfo(role, label, text, tag) {
1101
1697
  */
1102
1698
  function buildLocator(el, opts = {}) {
1103
1699
  const text = visibleText(el);
1700
+ const keep = opts.check !== void 0 ? keeper(el, opts.check) : always;
1701
+ const byName = !namedByText(el) || keep === always;
1104
1702
  const testId = el.getAttribute("data-testid");
1105
1703
  if (testId && document.querySelectorAll(`[data-testid="${CSS.escape(testId)}"]`).length === 1) {
1106
1704
  const name = collapse((accessibleName(el) || text).replace(/[-−–+]?\s?[$€£¥]?\s?\d[\d.,]*%?/g, " "), 30);
@@ -1113,39 +1711,79 @@ function buildLocator(el, opts = {}) {
1113
1711
  const role = roleFor(el);
1114
1712
  if (named(role)) {
1115
1713
  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)} })`,
1714
+ if (name && name.length <= 60) {
1715
+ const open = numbersOpen(name);
1716
+ if (open && (byName || keep(open)) && countByRoleAndName(role, open, true) === 1) return {
1717
+ code: `page.getByRole(${jsString(role)}, { name: ${open.code} })`,
1119
1718
  description: describeRole(role, name),
1120
1719
  text
1121
1720
  };
1122
- if (countByRoleAndName(role, name, true) === 1) return {
1123
- code: `page.getByRole(${jsString(role)}, { name: ${jsString(name)}, exact: true })`,
1124
- description: describeRole(role, name),
1721
+ if (!looksVolatile(name) && (byName || keep(name))) {
1722
+ if (countByRoleAndName(role, name, false) === 1) return {
1723
+ code: `page.getByRole(${jsString(role)}, { name: ${jsString(name)} })`,
1724
+ description: describeRole(role, name),
1725
+ text
1726
+ };
1727
+ if (countByRoleAndName(role, name, true) === 1) return {
1728
+ code: `page.getByRole(${jsString(role)}, { name: ${jsString(name)}, exact: true })`,
1729
+ description: describeRole(role, name),
1730
+ text
1731
+ };
1732
+ }
1733
+ } else if (name && byName) {
1734
+ const start = name.slice(0, 50).replace(/\s+\S*$/, "");
1735
+ if (start.length >= 12 && !looksVolatile(start) && countByRoleAndName(role, start, false) === 1) return {
1736
+ code: `page.getByRole(${jsString(role)}, { name: ${jsString(start)} })`,
1737
+ description: describeRole(role, `${start}…`),
1125
1738
  text
1126
1739
  };
1127
1740
  }
1128
1741
  }
1129
1742
  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`,
1743
+ const full = labelText(el);
1744
+ const label = full.replace(/…$/, "");
1745
+ if (label) {
1746
+ const by = (exact) => Array.from(document.querySelectorAll("input, textarea, select, [aria-label]")).filter((f) => sameWords(labelText(f) || f.getAttribute("aria-label") || "", label, exact)).length;
1747
+ if (by(false) === 1) return {
1748
+ code: `page.getByLabel(${jsString(label)})`,
1749
+ description: `the “${label}” field`,
1750
+ text
1751
+ };
1752
+ if (label === full && by(true) === 1) return {
1753
+ code: `page.getByLabel(${jsString(label)}, { exact: true })`,
1754
+ description: `the “${label}” field`,
1755
+ text
1756
+ };
1757
+ }
1758
+ const ph = el.getAttribute("placeholder");
1759
+ if (ph) {
1760
+ const by = (exact) => Array.from(document.querySelectorAll("[placeholder]")).filter((f) => sameWords(f.getAttribute("placeholder") ?? "", ph, exact)).length;
1761
+ if (by(false) === 1) return {
1762
+ code: `page.getByPlaceholder(${jsString(ph)})`,
1763
+ description: `the “${ph}” field`,
1764
+ text
1765
+ };
1766
+ if (by(true) === 1) return {
1767
+ code: `page.getByPlaceholder(${jsString(ph)}, { exact: true })`,
1768
+ description: `the “${ph}” field`,
1769
+ text
1770
+ };
1771
+ }
1772
+ }
1773
+ if ((!opts.cheap || document.getElementsByTagName("*").length < 2500) && text && text.length <= 40 && !isField(el)) {
1774
+ const open = numbersOpen(text);
1775
+ if (open && keep(open) && byText$1(document.body, open).length === 1) return {
1776
+ code: `page.getByText(${open.code})`,
1777
+ description: `“${text}”`,
1134
1778
  text
1135
1779
  };
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`,
1780
+ if (!looksVolatile(text) && keep(text) && byText$1(document.body, text).length === 1) return {
1781
+ code: `page.getByText(${jsString(text)}, { exact: true })`,
1782
+ description: `“${text}”`,
1140
1783
  text
1141
1784
  };
1142
1785
  }
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);
1786
+ return scoped(el, keep) ?? nth(el, keep);
1149
1787
  }
1150
1788
  //#endregion
1151
1789
  //#region ../../packages/vite/src/bridge/explore.ts
@@ -1175,20 +1813,39 @@ function fieldOf(el) {
1175
1813
  };
1176
1814
  return {
1177
1815
  type,
1178
- value: type === "password" ? el.value ? "••••" : "" : collapse(el.value, 60),
1179
- required: el.required
1816
+ value: collapse(fieldValue(el), 60),
1817
+ required: el.required,
1818
+ ...isSecretField(el) ? { secret: true } : {}
1180
1819
  };
1181
1820
  }
1821
+ /** 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. */
1822
+ function submitsOf(el) {
1823
+ if (!(el instanceof HTMLInputElement) || [
1824
+ "checkbox",
1825
+ "radio",
1826
+ "file",
1827
+ "submit",
1828
+ "button",
1829
+ "reset",
1830
+ "image"
1831
+ ].includes(el.type)) return void 0;
1832
+ const form = el.form;
1833
+ if (!form) return void 0;
1834
+ const button = form.querySelector("button:not([type]), button[type=\"submit\"], input[type=\"submit\"], input[type=\"image\"]");
1835
+ return collapse(button ? accessibleName(button) || visibleText(button) || button.value : accessibleName(form), 60) || void 0;
1836
+ }
1182
1837
  function control(el, offscreen) {
1183
1838
  const role = roleOf(el) ?? el.tagName.toLowerCase();
1184
1839
  const name = collapse(accessibleName(el) || visibleText(el) || el.getAttribute("placeholder") || el.getAttribute("title"), 60);
1185
1840
  const src = el.getAttribute("data-ds-src") ?? annotatedAncestor(el)?.getAttribute("data-ds-src") ?? null;
1186
1841
  const disabled = el.disabled === true || el.getAttribute("aria-disabled") === "true";
1842
+ const submits = submitsOf(el);
1187
1843
  return {
1188
1844
  locator: buildLocator(el, { cheap: true }).code,
1189
1845
  role,
1190
1846
  name,
1191
1847
  ...isField(el) ? { field: fieldOf(el) } : {},
1848
+ ...submits ? { submits } : {},
1192
1849
  disabled,
1193
1850
  offscreen,
1194
1851
  src
@@ -1212,10 +1869,10 @@ function snapshot() {
1212
1869
  const listed = placed.slice(0, MAX_CONTROLS);
1213
1870
  const focused = listed.findIndex((p) => p.el === document.activeElement);
1214
1871
  return {
1215
- url: location.href,
1872
+ url: redactUrl(location.href),
1216
1873
  title: document.title,
1217
1874
  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),
1875
+ text: collapse(maskSecrets(main.innerText), MAX_TEXT),
1219
1876
  controls: listed.map((p) => control(p.el, p.offscreen)),
1220
1877
  more: Math.max(0, placed.length - MAX_CONTROLS),
1221
1878
  dialog: dialog ? collapse(accessibleName(dialog) || dialog.querySelector("h1, h2, h3")?.textContent, 80) || "a dialog" : null,
@@ -1341,10 +1998,10 @@ function probe(x, y, radius, deep = false) {
1341
1998
  }
1342
1999
  var held = null;
1343
2000
  /**
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.
2001
+ * For an unsteady pointer (an older studio's hand control asks for it): the
2002
+ * highlight stays on what it is on until the point clearly leaves it (a few
2003
+ * pixels past its edge) or moves onto something inside it. Without this the
2004
+ * highlight flickers between neighbours.
1348
2005
  */
1349
2006
  function probeSticky(x, y, radius, deep = false) {
1350
2007
  const hit = probe(x, y, radius, deep);
@@ -1457,6 +2114,7 @@ var KINDS = {
1457
2114
  "textbox",
1458
2115
  "searchbox",
1459
2116
  "combobox",
2117
+ "listbox",
1460
2118
  "field"
1461
2119
  ],
1462
2120
  list: [
@@ -1615,9 +2273,22 @@ function parseLocator(code) {
1615
2273
  i++;
1616
2274
  return out;
1617
2275
  };
2276
+ /** A regular expression literal, as the recorder writes one for a name with its numbers left open: `/^Pay \$\d[\d.,]*$/`. */
2277
+ const regex = () => {
2278
+ const m = /^\/((?:[^/\\[\r\n]|\\.|\[(?:[^\]\\\r\n]|\\.)*\])+)\/([dgimsuy]*)/.exec(s.slice(i));
2279
+ if (!m) return null;
2280
+ try {
2281
+ const re = new RegExp(m[1], m[2].replace(/[gy]/g, ""));
2282
+ i += m[0].length;
2283
+ return re;
2284
+ } catch {
2285
+ return null;
2286
+ }
2287
+ };
1618
2288
  const value = () => {
1619
2289
  ws();
1620
2290
  if (s[i] === "'" || s[i] === "\"") return str();
2291
+ if (s[i] === "/") return regex();
1621
2292
  const num = /^-?\d+(?:\.\d+)?/.exec(s.slice(i));
1622
2293
  if (num) {
1623
2294
  i += num[0].length;
@@ -1641,7 +2312,7 @@ function parseLocator(code) {
1641
2312
  if (!key || s[i] !== ":") return null;
1642
2313
  i++;
1643
2314
  const v = value();
1644
- if (v === null || typeof v === "object") return null;
2315
+ if (v === null || typeof v === "object" && !(v instanceof RegExp)) return null;
1645
2316
  obj[key] = v;
1646
2317
  ws();
1647
2318
  if (s[i] === ",") i++;
@@ -1683,21 +2354,25 @@ function parseLocator(code) {
1683
2354
  return calls.length ? calls : null;
1684
2355
  }
1685
2356
  var norm = (s) => s.replace(/\s+/g, " ").trim();
1686
- /** Playwright's matching: case-insensitive and partial by default, exact (case and all) when asked. */
2357
+ /** Playwright's matching: case-insensitive and partial by default, exact (case and all) when asked; a pattern as it is. */
1687
2358
  function matches(actual, wanted, exact) {
1688
2359
  const a = norm(actual);
2360
+ if (wanted instanceof RegExp) return wanted.test(a);
1689
2361
  const w = norm(wanted);
1690
2362
  return exact ? a === w : a.toLowerCase().includes(w.toLowerCase());
1691
2363
  }
2364
+ /** A pattern loosened to find what holds a match somewhere in its text (`^…$` let go). */
2365
+ var loose = (re) => new RegExp(re.source.replace(/^\^/, "").replace(/(?<!\\)\$$/, ""), re.flags);
1692
2366
  /** Innermost elements whose text is (or, not exact, contains) `text`. */
1693
2367
  function byText(scope, text, exact) {
1694
- const needle = norm(text).toLowerCase();
2368
+ const needle = typeof text === "string" ? norm(text).toLowerCase() : "";
2369
+ const somewhere = text instanceof RegExp ? loose(text) : null;
1695
2370
  const hits = [];
1696
2371
  const all = scope.querySelectorAll("body *");
1697
2372
  for (let i = 0; i < all.length; i++) {
1698
2373
  const el = all[i];
1699
2374
  if (el instanceof HTMLScriptElement || el instanceof HTMLStyleElement) continue;
1700
- if (!norm(el.textContent ?? "").toLowerCase().includes(needle)) continue;
2375
+ if (somewhere ? !somewhere.test(norm(el.textContent ?? "")) : !norm(el.textContent ?? "").toLowerCase().includes(needle)) continue;
1701
2376
  if (!matches(isField(el) ? "" : visibleText(el), text, exact)) continue;
1702
2377
  hits.push(el);
1703
2378
  }
@@ -1749,29 +2424,30 @@ function elementsFor(calls, like) {
1749
2424
  };
1750
2425
  for (const c of calls) {
1751
2426
  const [a, b] = c.args;
1752
- const opt = typeof b === "object" ? b : {};
2427
+ const opt = typeof b === "object" && !(b instanceof RegExp) ? b : {};
2428
+ const text = (v) => v instanceof RegExp ? v : String(v);
1753
2429
  switch (c.name) {
1754
2430
  case "getByTestId":
1755
2431
  set = within((sc) => Array.from(sc.querySelectorAll(`[data-testid="${CSS.escape(String(a))}"]`)));
1756
2432
  break;
1757
2433
  case "getByRole": {
1758
2434
  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))));
2435
+ 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
2436
  break;
1761
2437
  }
1762
2438
  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)));
2439
+ const f = typeof a === "object" && !(a instanceof RegExp) ? a : {};
2440
+ 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
2441
  break;
1766
2442
  }
1767
2443
  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)));
2444
+ 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
2445
  break;
1770
2446
  case "getByPlaceholder":
1771
- set = within((sc) => Array.from(sc.querySelectorAll("[placeholder]")).filter((el) => matches(el.getAttribute("placeholder") ?? "", String(a), !!opt.exact)));
2447
+ set = within((sc) => Array.from(sc.querySelectorAll("[placeholder]")).filter((el) => matches(el.getAttribute("placeholder") ?? "", text(a), !!opt.exact)));
1772
2448
  break;
1773
2449
  case "getByText":
1774
- set = within((sc) => byText(sc, String(a), !!opt.exact));
2450
+ set = within((sc) => byText(sc, text(a), !!opt.exact));
1775
2451
  break;
1776
2452
  case "locator":
1777
2453
  try {
@@ -1812,14 +2488,34 @@ function find(code, like) {
1812
2488
  reason: all.length ? "hidden" : "missing"
1813
2489
  };
1814
2490
  }
2491
+ /** The option a recorded choice names: by its value, else by what it says (as it was recorded, "Express"). */
2492
+ function optionFor(el, wanted) {
2493
+ const opts = Array.from(el.options);
2494
+ const said = (o) => norm(o.label || o.text);
2495
+ return opts.find((x) => x.value === wanted) ?? opts.find((x) => said(x) === norm(wanted)) ?? opts.find((x) => said(x).toLowerCase() === norm(wanted).toLowerCase());
2496
+ }
2497
+ /** Choose these options (all of them, in a list that takes several), as a person would: then input and change. */
2498
+ function choose(el, wanted) {
2499
+ const picked = wanted.map((w) => optionFor(el, w));
2500
+ if (!picked.length || picked.some((o) => !o)) throw new Error("no-option");
2501
+ if (el.multiple) for (const o of Array.from(el.options)) o.selected = picked.includes(o);
2502
+ else {
2503
+ const setter = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, "value")?.set;
2504
+ if (setter) setter.call(el, picked[0].value);
2505
+ else el.value = picked[0].value;
2506
+ }
2507
+ el.dispatchEvent(new Event("input", { bubbles: true }));
2508
+ el.dispatchEvent(new Event("change", { bubbles: true }));
2509
+ }
2510
+ /** A control a person couldn't use now: disabled (itself or its fieldset), marked so, or in an inert part of the page. */
2511
+ function unusable(el) {
2512
+ if (el.matches(":disabled")) return true;
2513
+ if (el.closest("[inert]")) return true;
2514
+ return el.closest("[aria-disabled]")?.getAttribute("aria-disabled") === "true";
2515
+ }
1815
2516
  /** Set a field's value the way React expects (native setter, then input and change). */
1816
2517
  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
- }
2518
+ if (el instanceof HTMLSelectElement) return choose(el, [value]);
1823
2519
  if (el instanceof HTMLInputElement && el.type === "file") throw new Error("file");
1824
2520
  const proto = el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : el instanceof HTMLSelectElement ? HTMLSelectElement.prototype : HTMLInputElement.prototype;
1825
2521
  const setter = Object.getOwnPropertyDescriptor(proto, "value")?.set;
@@ -1850,8 +2546,14 @@ var mouse = (type, x, y) => new MouseEvent(type, {
1850
2546
  buttons: type === "mousedown" ? 1 : 0,
1851
2547
  detail: 1
1852
2548
  });
1853
- /** Do one step on an element: as a person would, so the app (and its recorder) sees the same events. */
2549
+ /**
2550
+ * Do one step on an element: as a person would, so the app (and its recorder) sees the same events. A control a
2551
+ * person couldn't use now (disabled, inert) is refused: clicking it would do nothing, and the replay would go on as
2552
+ * if it had worked.
2553
+ */
1854
2554
  function act(el, step) {
2555
+ if (unusable(el)) throw new Error("disabled");
2556
+ scriptedInput();
1855
2557
  const r = el.getBoundingClientRect();
1856
2558
  const x = r.left + r.width / 2;
1857
2559
  const y = r.top + r.height / 2;
@@ -1873,6 +2575,7 @@ function act(el, step) {
1873
2575
  if (el.checked !== (step.checked ?? !el.checked)) el.click();
1874
2576
  return;
1875
2577
  }
2578
+ if (el instanceof HTMLSelectElement && step.select?.length) return choose(el, step.select);
1876
2579
  setValue(el, step.value ?? "");
1877
2580
  return;
1878
2581
  }
@@ -1903,9 +2606,10 @@ function act(el, step) {
1903
2606
  if (down && key === "Enter" && el instanceof HTMLInputElement) el.form?.requestSubmit();
1904
2607
  }
1905
2608
  }
1906
- /** What an element shows now, for checking what the tester said it should show. */
2609
+ /** What an element shows now, for checking what the tester said it should show (a secret as dots). */
1907
2610
  function textOf$1(el) {
1908
- return isField(el) ? el.value : collapse(el.innerText ?? el.textContent ?? "", 400);
2611
+ if (el instanceof HTMLSelectElement) return Array.from(el.selectedOptions, (o) => norm(o.label || o.text)).join(", ");
2612
+ return isField(el) ? fieldValue(el) : collapse(el.innerText ?? el.textContent ?? "", 400);
1909
2613
  }
1910
2614
  //#endregion
1911
2615
  //#region ../../packages/vite/src/bridge/forms.ts
@@ -1961,13 +2665,37 @@ var TEXTUAL = /* @__PURE__ */ new Set([
1961
2665
  "number",
1962
2666
  ""
1963
2667
  ]);
2668
+ /** Inputs whose value is picked rather than typed: a test sets it (fill does, as the input keeps it: 2026-11-20). */
2669
+ var PICKED = /* @__PURE__ */ new Set([
2670
+ "date",
2671
+ "time",
2672
+ "datetime-local",
2673
+ "month",
2674
+ "week",
2675
+ "color",
2676
+ "range"
2677
+ ]);
2678
+ var KEY_NAME = {
2679
+ ArrowDown: "↓",
2680
+ ArrowUp: "↑",
2681
+ Escape: "Escape"
2682
+ };
1964
2683
  function isTextField(el) {
1965
2684
  if (el instanceof HTMLTextAreaElement) return true;
1966
2685
  return el instanceof HTMLInputElement && TEXTUAL.has((el.type || "").toLowerCase());
1967
2686
  }
2687
+ var isPicked = (el) => el instanceof HTMLInputElement && PICKED.has((el.type || "").toLowerCase());
2688
+ var isToggle = (el) => el instanceof HTMLInputElement && (el.type === "checkbox" || el.type === "radio");
2689
+ /** A field that offers a list to choose from as you type (a WAI-ARIA combobox, or an input with a datalist). */
2690
+ var isCombobox = (el) => el.getAttribute("role") === "combobox" || el.hasAttribute("aria-autocomplete") || el instanceof HTMLInputElement && !!el.list;
2691
+ /** What an option is called: its label, else its text. */
2692
+ var optionName = (o) => collapse(o.label || o.text, 80);
1968
2693
  /**
1969
2694
  * 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.
2695
+ * entry (one `fill` per field, flushed on change, blur or Enter), choices in
2696
+ * lists (every option chosen, in a list that takes several) and Enter. A
2697
+ * secret (a password, a card number…) is recorded by its name alone: what was
2698
+ * typed never leaves the page.
1971
2699
  */
1972
2700
  var Recorder = class {
1973
2701
  emit;
@@ -1976,24 +2704,41 @@ var Recorder = class {
1976
2704
  submits = new EnterSubmits();
1977
2705
  constructor(emit) {
1978
2706
  this.emit = emit;
2707
+ document.addEventListener("pointerdown", this.onPointerDown, true);
1979
2708
  document.addEventListener("click", this.onClick, true);
1980
2709
  document.addEventListener("input", this.onInput, true);
1981
2710
  document.addEventListener("change", this.onChange, true);
1982
2711
  document.addEventListener("keydown", this.onKeyDown, true);
1983
2712
  document.addEventListener("blur", this.onBlur, true);
2713
+ document.addEventListener("focusin", this.onFocus, true);
1984
2714
  }
1985
2715
  setOn(on) {
1986
2716
  if (!on) this.flushAll();
1987
2717
  this.on = on;
1988
2718
  }
2719
+ /** Send the text still being typed now: what comes next (a check said about the page) comes after it. */
2720
+ flushAll() {
2721
+ for (const el of Array.from(this.pendingFill.keys())) this.flush(el);
2722
+ }
2723
+ onFocus = (e) => {
2724
+ if (this.on && e.target instanceof Element && isField(e.target)) isSecretField(e.target);
2725
+ };
2726
+ /** What was being typed comes before what is clicked now: a combobox's suggestion keeps the focus in its field. */
2727
+ onPointerDown = (e) => {
2728
+ if (!this.on) return;
2729
+ const t = e.target instanceof Node ? e.target : null;
2730
+ for (const el of Array.from(this.pendingFill.keys())) if (!t || !el.contains(t)) this.flush(el);
2731
+ };
1989
2732
  onClick = (e) => {
1990
2733
  if (!this.on) return;
1991
2734
  const t = e.target instanceof Element ? e.target : null;
1992
2735
  if (!t) return;
1993
2736
  const el = t.closest(INTERACTIVE$1) ?? t;
1994
- if (isTextField(el)) return;
2737
+ if (isTextField(el) || isPicked(el)) return;
2738
+ if (isToggle(el)) return;
2739
+ if (el instanceof HTMLSelectElement || el instanceof HTMLOptionElement) return;
1995
2740
  if (this.submits.isSubmitClick(e, el)) return;
1996
- if (el instanceof HTMLLabelElement && el.control && isTextField(el.control)) return;
2741
+ if (el instanceof HTMLLabelElement && el.control && (isTextField(el.control) || el.control instanceof HTMLSelectElement || isPicked(el.control) || isToggle(el.control))) return;
1997
2742
  const loc = buildLocator(el);
1998
2743
  this.emit({
1999
2744
  kind: "click",
@@ -2014,13 +2759,32 @@ var Recorder = class {
2014
2759
  const el = e.target;
2015
2760
  if (!(el instanceof Element)) return;
2016
2761
  if (isTextField(el)) this.flush(el);
2017
- else if (el instanceof HTMLSelectElement) {
2762
+ else if (isPicked(el)) {
2763
+ const prev = this.pendingFill.get(el);
2764
+ if (prev) clearTimeout(prev);
2765
+ this.pendingFill.set(el, setTimeout(() => this.flush(el), 600));
2766
+ } else if (isToggle(el)) {
2018
2767
  const loc = buildLocator(el);
2768
+ const did = el.type === "radio" ? "Choose" : el.checked ? "Check" : "Uncheck";
2019
2769
  this.emit({
2020
2770
  kind: "fill",
2021
2771
  locator: loc.code,
2022
- value: el.value,
2023
- description: `Choose “${el.value}” in ${loc.description}`
2772
+ value: "",
2773
+ checked: el.checked,
2774
+ description: `${did} ${loc.description}`
2775
+ });
2776
+ } else if (el instanceof HTMLSelectElement) {
2777
+ const loc = buildLocator(el);
2778
+ const chosen = Array.from(el.selectedOptions);
2779
+ const names = chosen.map(optionName);
2780
+ const said = names.length ? names.map((n) => `“${n}”`).join(", ") : "nothing";
2781
+ this.emit({
2782
+ kind: "fill",
2783
+ locator: loc.code,
2784
+ value: chosen[0]?.value ?? "",
2785
+ select: names,
2786
+ values: chosen.map((o) => o.value),
2787
+ description: `Choose ${said} in ${loc.description}`
2024
2788
  });
2025
2789
  }
2026
2790
  };
@@ -2030,7 +2794,21 @@ var Recorder = class {
2030
2794
  if (el instanceof Element && this.pendingFill.has(el)) this.flush(el);
2031
2795
  };
2032
2796
  onKeyDown = (e) => {
2033
- if (!this.on || e.key !== "Enter") return;
2797
+ if (!this.on) return;
2798
+ if (KEY_NAME[e.key]) {
2799
+ const el = e.target;
2800
+ if (!(el instanceof Element) || !isCombobox(el)) return;
2801
+ if (this.pendingFill.has(el)) this.flush(el);
2802
+ const loc = buildLocator(el);
2803
+ this.emit({
2804
+ kind: "press",
2805
+ locator: loc.code,
2806
+ key: e.key,
2807
+ description: `Press ${KEY_NAME[e.key]} in ${loc.description}`
2808
+ });
2809
+ return;
2810
+ }
2811
+ if (e.key !== "Enter") return;
2034
2812
  this.submits.keydown(e);
2035
2813
  const el = e.target;
2036
2814
  if (!(el instanceof Element) || !isField(el)) return;
@@ -2047,48 +2825,52 @@ var Recorder = class {
2047
2825
  const t = this.pendingFill.get(el);
2048
2826
  if (t) clearTimeout(t);
2049
2827
  if (!this.pendingFill.delete(el)) return;
2828
+ if (isPicked(el)) {
2829
+ const loc = buildLocator(el);
2830
+ this.emit({
2831
+ kind: "fill",
2832
+ locator: loc.code,
2833
+ value: el.value,
2834
+ description: `Set ${loc.description} to “${collapse(el.value, 60)}”`
2835
+ });
2836
+ return;
2837
+ }
2050
2838
  if (!isTextField(el)) return;
2051
2839
  const loc = buildLocator(el);
2840
+ if (isSecretField(el)) {
2841
+ this.emit({
2842
+ kind: "fill",
2843
+ locator: loc.code,
2844
+ value: "",
2845
+ secret: secretNameOf(el),
2846
+ description: `Type ${MASK} into ${loc.description}`
2847
+ });
2848
+ return;
2849
+ }
2850
+ const value = maskSecrets(el.value);
2052
2851
  this.emit({
2053
2852
  kind: "fill",
2054
2853
  locator: loc.code,
2055
- value: el.value,
2056
- description: `Type “${el.value}” into ${loc.description}`
2854
+ value,
2855
+ description: `Type “${collapse(value, 60)}” into ${loc.description}`
2057
2856
  });
2058
2857
  }
2059
- flushAll() {
2060
- for (const el of Array.from(this.pendingFill.keys())) this.flush(el);
2061
- }
2062
2858
  };
2063
2859
  //#endregion
2064
- //#region ../../packages/vite/src/bridge/own.ts
2860
+ //#region ../../packages/vite/src/bridge/proxy.ts
2065
2861
  /**
2066
- * Saywright's own work on the app's page (reading the screen, building
2067
- * locators, answering the studio): timed, so a slow moment it caused is never
2068
- * reported as the app's.
2862
+ * When the studio shows a site through its proxy, the site's own absolute
2863
+ * addresses (https://example.com/...) would leave the proxy: requests,
2864
+ * sockets, workers, links, forms and navigations are pointed back into it
2865
+ * before the site's scripts run. A site on this computer that calls another
2866
+ * port of it (its API at http://localhost:5010) has those requests go through
2867
+ * the proxy too: from the proxy's address they would meet the API's CORS
2868
+ * rules as a stranger. The proxy applies those rules as the browser would
2869
+ * (each request says whether it carries the page's credentials). Cookies are kept under the site's own prefix in the
2870
+ * browser (cookies ignore ports, and other apps share localhost): the site's
2871
+ * scripts see their own names. Service workers are kept off, so an old cached
2872
+ * page never hides the studio.
2069
2873
  */
2070
- var spans = [];
2071
- /** Run some of our own work, remembering when it ran if it took long enough to matter. */
2072
- function own(fn) {
2073
- const start = performance.now();
2074
- try {
2075
- return fn();
2076
- } finally {
2077
- const end = performance.now();
2078
- if (end - start >= 8) {
2079
- spans.push([start, end]);
2080
- if (spans.length > 64) spans.splice(0, spans.length - 64);
2081
- }
2082
- }
2083
- }
2084
- /** How much of [start, end] (performance.now() times) was our own work. */
2085
- function ownTime(start, end) {
2086
- let ms = 0;
2087
- for (const [a, b] of spans) ms += Math.max(0, Math.min(b, end) - Math.max(a, start));
2088
- return ms;
2089
- }
2090
- //#endregion
2091
- //#region ../../packages/vite/src/bridge/proxy.ts
2092
2874
  /** This computer's own names. */
2093
2875
  var LOOPBACK$1 = /^(localhost|127\.0\.0\.1|\[::1\])$/;
2094
2876
  var portOf = (u) => u.port || (u.protocol === "https:" || u.protocol === "wss:" ? "443" : "80");
@@ -2098,7 +2880,7 @@ var portOf = (u) => u.port || (u.protocol === "https:" || u.protocol === "wss:"
2098
2880
  * site is on this computer: another port of it → the proxy's path for that port, /__saywright/<http|https>/<host:port>/…,
2099
2881
  * so the request leaves from the site's own address, as it does outside the studio. Anything else stays as it is.
2100
2882
  */
2101
- function viaProxy(input, base, c, requests) {
2883
+ function viaProxy(input, base, c, requests, credentials = false) {
2102
2884
  try {
2103
2885
  const u = new URL(input, base);
2104
2886
  const up = new URL(c.upstream);
@@ -2111,7 +2893,7 @@ function viaProxy(input, base, c, requests) {
2111
2893
  if (!requests || !/^(https?|wss?):$/.test(u.protocol) || !LOOPBACK$1.test(up.hostname) || !LOOPBACK$1.test(u.hostname)) return input;
2112
2894
  if (portOf(u) === portOf(proxy)) return input;
2113
2895
  if (c.studio && portOf(u) === portOf(new URL(c.studio))) return input;
2114
- const scheme = u.protocol === "https:" || u.protocol === "wss:" ? "https" : "http";
2896
+ const scheme = (u.protocol === "https:" || u.protocol === "wss:" ? "https" : "http") + (credentials ? "+cred" : "");
2115
2897
  return `${u.protocol.startsWith("ws") ? "ws:" : proxy.protocol}//${proxy.host}/__saywright/${scheme}/${u.hostname}:${portOf(u)}` + u.pathname + u.search + u.hash;
2116
2898
  } catch {
2117
2899
  return input;
@@ -2124,22 +2906,35 @@ function proxyConfig() {
2124
2906
  function installProxyRewrites(c) {
2125
2907
  /** Navigations, workers, history: the site's own address only. */
2126
2908
  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);
2909
+ /** Requests: its API on another port of this computer too, saying whether they carry the page's credentials. */
2910
+ const toRequest = (input, credentials = false) => viaProxy(input, location.href, c, true, credentials);
2129
2911
  const mapped = (fn) => (url) => typeof url === "string" ? fn(url) : url instanceof URL ? new URL(fn(url.href)) : url;
2130
2912
  const toAny = mapped(to);
2131
- const requestAny = mapped(toRequest);
2913
+ const requestAny = mapped((u) => toRequest(u));
2914
+ /** Sockets and beacons always carry the site's cookies. */
2915
+ const requestWithCredentials = mapped((u) => toRequest(u, true));
2132
2916
  const nativeFetch = window.fetch.bind(window);
2133
2917
  window.fetch = (input, init) => {
2918
+ const credentials = (init?.credentials ?? (input instanceof Request ? input.credentials : "same-origin")) === "include";
2134
2919
  if (input instanceof Request) {
2135
- const next = toRequest(input.url);
2920
+ const next = toRequest(input.url, credentials);
2136
2921
  return nativeFetch(next === input.url ? input : new Request(next, input), init);
2137
2922
  }
2138
- return nativeFetch(requestAny(input), init);
2923
+ const url = typeof input === "string" ? toRequest(input, credentials) : new URL(toRequest(input.href, credentials));
2924
+ return nativeFetch(url, init);
2139
2925
  };
2926
+ const proxied = /* @__PURE__ */ new WeakSet();
2140
2927
  const open = XMLHttpRequest.prototype.open;
2141
2928
  XMLHttpRequest.prototype.open = function(method, url, ...rest) {
2142
- return open.call(this, method, requestAny(url), ...rest);
2929
+ const next = requestAny(url);
2930
+ if (String(next).includes("/__saywright/")) proxied.add(this);
2931
+ else proxied.delete(this);
2932
+ return open.call(this, method, next, ...rest);
2933
+ };
2934
+ const send = XMLHttpRequest.prototype.send;
2935
+ XMLHttpRequest.prototype.send = function(body) {
2936
+ if (proxied.has(this) && this.withCredentials) this.setRequestHeader(PROXY_CREDENTIALS_HEADER, "include");
2937
+ return send.call(this, body);
2143
2938
  };
2144
2939
  const wrap = (Native, map) => new Proxy(Native, { construct(target, args) {
2145
2940
  if (args.length) args[0] = map(args[0]);
@@ -2147,17 +2942,20 @@ function installProxyRewrites(c) {
2147
2942
  } });
2148
2943
  const w = window;
2149
2944
  for (const [name, map] of [
2150
- ["WebSocket", requestAny],
2151
- ["EventSource", requestAny],
2945
+ ["WebSocket", requestWithCredentials],
2152
2946
  ["Worker", toAny],
2153
2947
  ["SharedWorker", toAny]
2154
2948
  ]) {
2155
2949
  const Native = w[name];
2156
2950
  if (Native) w[name] = wrap(Native, map);
2157
2951
  }
2952
+ if (w.EventSource) w.EventSource = new Proxy(w.EventSource, { construct(target, args) {
2953
+ if (args.length) args[0] = args[1]?.withCredentials ? requestWithCredentials(args[0]) : requestAny(args[0]);
2954
+ return Reflect.construct(target, args);
2955
+ } });
2158
2956
  if (navigator.sendBeacon) {
2159
2957
  const beacon = navigator.sendBeacon.bind(navigator);
2160
- navigator.sendBeacon = (url, data) => beacon(requestAny(url), data);
2958
+ navigator.sendBeacon = (url, data) => beacon(requestWithCredentials(url), data);
2161
2959
  }
2162
2960
  for (const name of ["pushState", "replaceState"]) {
2163
2961
  const native = History.prototype[name];
@@ -2248,6 +3046,73 @@ function installCookieNamespace(prefix) {
2248
3046
  };
2249
3047
  store.delete = (arg) => del(named(arg));
2250
3048
  }
3049
+ //#endregion
3050
+ //#region ../../packages/vite/src/bridge/mask.ts
3051
+ /**
3052
+ * While a session records, the screen it shares shows every secret field as dots and blurs what is marked private:
3053
+ * its screenshots and video are pictures of the screen, so this is what keeps a card number, a one-time code or a
3054
+ * key out of them, as the recorded text keeps it out of the events. The same rules decide what is secret (see dom.ts).
3055
+ */
3056
+ var ATTR = "data-saywright-masked";
3057
+ var STYLE_ID = "saywright-mask";
3058
+ var observer = null;
3059
+ function mark(el) {
3060
+ if (isField(el) && !(el instanceof HTMLSelectElement) && isSecretField(el)) el.setAttribute(ATTR, "");
3061
+ }
3062
+ function scan(root) {
3063
+ for (const el of Array.from(root.querySelectorAll("input, textarea"))) mark(el);
3064
+ }
3065
+ /** A field can become secret as it is used: a card number typed into one, a password shown as text. */
3066
+ var onUse = (e) => {
3067
+ if (e.target instanceof Element) mark(e.target);
3068
+ };
3069
+ function maskScreen(on) {
3070
+ if (typeof document === "undefined") return;
3071
+ if (!on) {
3072
+ observer?.disconnect();
3073
+ observer = null;
3074
+ removeEventListener("input", onUse, true);
3075
+ removeEventListener("focusin", onUse, true);
3076
+ document.getElementById(STYLE_ID)?.remove();
3077
+ for (const el of Array.from(document.querySelectorAll(`[${ATTR}]`))) el.removeAttribute(ATTR);
3078
+ return;
3079
+ }
3080
+ if (observer) return;
3081
+ const style = document.createElement("style");
3082
+ style.id = STYLE_ID;
3083
+ style.textContent = `[${ATTR}]{-webkit-text-security:disc!important}${PRIVATE.split(",").map((s) => `${s.trim()}:not(input):not(textarea)`).join(",")}{filter:blur(6px)!important}`;
3084
+ document.documentElement.appendChild(style);
3085
+ scan(document);
3086
+ observer = new MutationObserver((records) => {
3087
+ for (const r of records) {
3088
+ if (r.type === "attributes") {
3089
+ if (r.target instanceof Element) mark(r.target);
3090
+ continue;
3091
+ }
3092
+ for (const n of Array.from(r.addedNodes)) {
3093
+ if (!(n instanceof Element)) continue;
3094
+ mark(n);
3095
+ scan(n);
3096
+ }
3097
+ }
3098
+ });
3099
+ observer.observe(document.documentElement, {
3100
+ childList: true,
3101
+ subtree: true,
3102
+ attributes: true,
3103
+ attributeFilter: [
3104
+ "type",
3105
+ "name",
3106
+ "id",
3107
+ "autocomplete",
3108
+ "aria-label",
3109
+ "placeholder",
3110
+ "inputmode"
3111
+ ]
3112
+ });
3113
+ addEventListener("input", onUse, true);
3114
+ addEventListener("focusin", onUse, true);
3115
+ }
2251
3116
  /** What a request that may outlive the page can carry: browsers allow 64 KB for all of them together. */
2252
3117
  var KEEPALIVE_BYTES = 6e4;
2253
3118
  /** How soon a send follows an event that matters now (an error, a page change), and any other. */
@@ -2576,9 +3441,9 @@ var RequestTally = class {
2576
3441
  */
2577
3442
  var ACTIVE_KEY = "saywright.session";
2578
3443
  var PENDING_KEY = "saywright.session.pending";
3444
+ /** What counts as another target near a small one, as the Experts count it (a box's own label is part of it). */
3445
+ var TARGETS = "a[href], button, input:not([type=hidden]), select, textarea, summary, [role=\"button\"], [role=\"link\"], [role=\"checkbox\"], [role=\"radio\"], [role=\"switch\"], [role=\"tab\"], [role=\"menuitem\"], [tabindex]:not([tabindex=\"-1\"])";
2579
3446
  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
3447
  /** Requests that are the dev server or the studio talking, not the app. */
2583
3448
  var PLUMBING_URL = /\/@vite\/|\/@fs\/|\/@id\/|\/@react-refresh|\/node_modules\/|__saywright|\.hot-update\.|\/_next\/webpack-hmr|\/__nextjs|sockjs-node|\/api\/sessions\/|\/api\/speech\//;
2584
3449
  /** Where a session's events go. */
@@ -2687,6 +3552,20 @@ function shortUrl(url) {
2687
3552
  return url;
2688
3553
  }
2689
3554
  }
3555
+ /**
3556
+ * Whether something else to click is within 12 px of a small target's centre, where a 24 px circle on it would reach
3557
+ * (WCAG 2.5.8): looked for around it, so it costs the same on a page of any size.
3558
+ */
3559
+ function crowded(el, r) {
3560
+ const cx = r.left + r.width / 2;
3561
+ const cy = r.top + r.height / 2;
3562
+ for (const radius of [6, 11.5]) for (let k = 0; k < 16; k++) {
3563
+ const a = k / 16 * 2 * Math.PI;
3564
+ const other = document.elementFromPoint(cx + radius * Math.cos(a), cy + radius * Math.sin(a))?.closest(TARGETS);
3565
+ if (other && other !== el && !el.contains(other) && !other.contains(el)) return true;
3566
+ }
3567
+ return false;
3568
+ }
2690
3569
  var SessionRecorder = class {
2691
3570
  onStats;
2692
3571
  onMoment;
@@ -2757,6 +3636,7 @@ var SessionRecorder = class {
2757
3636
  writeJson(ACTIVE_KEY, null);
2758
3637
  this.active = null;
2759
3638
  this.outbox = null;
3639
+ maskScreen(false);
2760
3640
  outbox?.retire();
2761
3641
  this.report();
2762
3642
  }
@@ -2766,6 +3646,7 @@ var SessionRecorder = class {
2766
3646
  writeJson(ACTIVE_KEY, null);
2767
3647
  this.active = null;
2768
3648
  this.outbox = null;
3649
+ maskScreen(false);
2769
3650
  this.report();
2770
3651
  }
2771
3652
  /** Record what is still being put together: a burst's count, a hover, the pointer's trail, typing, counted requests. */
@@ -2789,6 +3670,7 @@ var SessionRecorder = class {
2789
3670
  }
2790
3671
  begin(info, resumed) {
2791
3672
  this.active = info;
3673
+ maskScreen(true);
2792
3674
  this.outbox = new Outbox(this.page, {
2793
3675
  post: (body, keepalive) => deliver(eventsUrl(info), body, keepalive),
2794
3676
  ended: () => this.endedElsewhere(info.id),
@@ -2829,11 +3711,11 @@ var SessionRecorder = class {
2829
3711
  }
2830
3712
  record(body, t, urgent = false) {
2831
3713
  if (!this.active || !this.outbox) return;
2832
- this.outbox.push({
3714
+ this.outbox.push(scrubEvent({
2833
3715
  ...body,
2834
3716
  seq: ++this.seq,
2835
3717
  t: Math.round(t)
2836
- }, urgent);
3718
+ }), urgent);
2837
3719
  this.stats.events++;
2838
3720
  if ([
2839
3721
  "error",
@@ -2922,6 +3804,7 @@ var SessionRecorder = class {
2922
3804
  this.fibers.set(t, fiber);
2923
3805
  }
2924
3806
  if (tag === "a" && getComputedStyle(el).display === "inline") t.inline = true;
3807
+ if ((r.width < 24 || r.height < 24) && r.width > 0 && r.height > 0 && !crowded(el, r)) t.spaced = true;
2925
3808
  return t;
2926
3809
  }
2927
3810
  interactive(el) {
@@ -3083,7 +3966,7 @@ var SessionRecorder = class {
3083
3966
  onInput = (e) => {
3084
3967
  if (!this.active || !(e.target instanceof Element) || !isField(e.target) || ours(e.target)) return;
3085
3968
  const el = e.target;
3086
- if (el instanceof HTMLInputElement && [
3969
+ if (el instanceof HTMLSelectElement || el instanceof HTMLInputElement && [
3087
3970
  "checkbox",
3088
3971
  "radio",
3089
3972
  "file",
@@ -3102,13 +3985,14 @@ var SessionRecorder = class {
3102
3985
  this.fieldTimes.delete(el);
3103
3986
  if (!this.fieldTimers.delete(el) || !isField(el)) return;
3104
3987
  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") ?? ""}`);
3988
+ const secret = isSecretField(el);
3106
3989
  const target = this.target(el);
3107
3990
  this.recordLater({
3108
3991
  type: "input",
3109
3992
  el: target,
3110
- value: secret ? "•".repeat(Math.min(value.length, 12)) : collapse(value, 500),
3111
- masked: secret
3993
+ value: secret ? dots(value) : collapse(value, 500),
3994
+ masked: secret,
3995
+ ...secret ? { secret: secretNameOf(el) } : {}
3112
3996
  }, typed, [[target, el]]);
3113
3997
  }
3114
3998
  flushFields() {
@@ -3128,12 +4012,14 @@ var SessionRecorder = class {
3128
4012
  "time"
3129
4013
  ].includes(el.type)) {
3130
4014
  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;
4015
+ const options = el instanceof HTMLSelectElement ? Array.from(el.selectedOptions, (o) => collapse(o.label || o.text, 80)) : void 0;
4016
+ const value = el instanceof HTMLSelectElement ? options[0] ?? el.value : el.type === "file" ? Array.from(el.files ?? []).map((f) => f.name).join(", ") : el.value;
3132
4017
  this.recordLater({
3133
4018
  type: "change",
3134
4019
  el: target,
3135
4020
  value: collapse(value, 200),
3136
- checked: el instanceof HTMLInputElement && (el.type === "checkbox" || el.type === "radio") ? el.checked : void 0
4021
+ checked: el instanceof HTMLInputElement && (el.type === "checkbox" || el.type === "radio") ? el.checked : void 0,
4022
+ ...options && options.length > 1 ? { options } : {}
3137
4023
  }, eventTime(e), [[target, el]]);
3138
4024
  }
3139
4025
  };
@@ -3178,6 +4064,7 @@ var SessionRecorder = class {
3178
4064
  };
3179
4065
  onFocus = (e) => {
3180
4066
  if (!this.active || !(e.target instanceof Element) || !isField(e.target) || ours(e.target)) return;
4067
+ isSecretField(e.target);
3181
4068
  const target = this.target(e.target);
3182
4069
  this.recordLater({
3183
4070
  type: "focus",
@@ -3257,12 +4144,14 @@ var SessionRecorder = class {
3257
4144
  if (!this.active || location.href === this.lastNavUrl) return;
3258
4145
  this.lastNavUrl = location.href;
3259
4146
  this.flushFields();
3260
- this.record({
4147
+ const at = now();
4148
+ const url = location.href;
4149
+ setTimeout(() => this.active && this.record({
3261
4150
  type: "nav",
3262
- url: location.href,
4151
+ url,
3263
4152
  title: document.title,
3264
4153
  how
3265
- }, now(), true);
4154
+ }, at, true), 120);
3266
4155
  setTimeout(() => this.snapshot("navigation"), 700);
3267
4156
  }
3268
4157
  onError = (e) => {
@@ -3511,7 +4400,7 @@ var SessionRecorder = class {
3511
4400
  });
3512
4401
  observe("layout-shift", (es) => {
3513
4402
  for (const e of es) {
3514
- if (e.hadRecentInput || e.startTime < this.since) continue;
4403
+ if (e.hadRecentInput || scriptedBefore(e.startTime) || e.startTime < this.since) continue;
3515
4404
  this.cls += e.value;
3516
4405
  if (e.value < .02) continue;
3517
4406
  const node = e.sources?.find((s) => s.node instanceof Element)?.node;
@@ -3880,6 +4769,8 @@ var MicStream = class {
3880
4769
  };
3881
4770
  //#endregion
3882
4771
  //#region ../../packages/vite/src/client/screen.ts
4772
+ /** How long starting waits for the shared picture's first frame. */
4773
+ var FIRST_FRAME_WAIT_MS = 1500;
3883
4774
  function videoMime() {
3884
4775
  for (const m of [
3885
4776
  "video/webm;codecs=vp9",
@@ -3941,8 +4832,8 @@ var ScreenCapture = class {
3941
4832
  video.muted = true;
3942
4833
  video.playsInline = true;
3943
4834
  video.srcObject = stream;
3944
- await video.play().catch(() => void 0);
3945
4835
  this.video = video;
4836
+ await Promise.race([video.play().catch(() => void 0), new Promise((resolve) => setTimeout(resolve, FIRST_FRAME_WAIT_MS))]);
3946
4837
  }
3947
4838
  /** Start the video (when `video`): the session has begun. */
3948
4839
  record(video) {
@@ -3966,7 +4857,11 @@ var ScreenCapture = class {
3966
4857
  /** Take a screenshot now (at most one every 2.5 s, and 150 per session, unless forced). */
3967
4858
  shoot(reason, force = false) {
3968
4859
  const video = this.video;
3969
- if (!video || !this.active || !video.videoWidth) return;
4860
+ if (!video || !this.active) return;
4861
+ if (!video.videoWidth) {
4862
+ if (force) video.addEventListener("loadeddata", () => this.video === video && this.shoot(reason, true), { once: true });
4863
+ return;
4864
+ }
3970
4865
  const now = Date.now();
3971
4866
  if (!force && (now - this.lastShot < 2500 || this.shotCount >= 150)) return;
3972
4867
  this.lastShot = now;
@@ -4009,7 +4904,17 @@ var ScreenCapture = class {
4009
4904
  * the studio and by the recorder inside the app's own tab.
4010
4905
  */
4011
4906
  var PENDING_AUDIO = "saywright.session.audio";
4012
- /** How long starting waits for the browser's “share this tab” question to be answered. */
4907
+ /** After `ms`, once the page has drawn again (a page not drawing, in the background, doesn't hold it up). */
4908
+ function painted(ms) {
4909
+ return new Promise((resolve) => setTimeout(() => {
4910
+ if (typeof requestAnimationFrame !== "function") return resolve();
4911
+ const done = setTimeout(resolve, 100);
4912
+ requestAnimationFrame(() => requestAnimationFrame(() => {
4913
+ clearTimeout(done);
4914
+ resolve();
4915
+ }));
4916
+ }, ms));
4917
+ }
4013
4918
  var SCREEN_WAIT_MS = 3e4;
4014
4919
  function toBase64(buf) {
4015
4920
  const bytes = new Uint8Array(buf);
@@ -4465,7 +5370,7 @@ var SessionClient = class {
4465
5370
  status: "failed",
4466
5371
  countdown: null,
4467
5372
  mic: "off",
4468
- error: "The studio isn’t reachable. Is `npm run dev` running?"
5373
+ error: "The studio isn’t reachable. Is it still running? Start it again with `npx @saywright/studio`."
4469
5374
  });
4470
5375
  throw new Error(this.state.error);
4471
5376
  }
@@ -4497,6 +5402,7 @@ var SessionClient = class {
4497
5402
  id,
4498
5403
  countdown: null
4499
5404
  });
5405
+ if (this.screen && opts.countdown) await painted(220);
4500
5406
  this.screen?.record(opts.video);
4501
5407
  try {
4502
5408
  await opts.onCreated?.(id, this.key);
@@ -4758,6 +5664,8 @@ var SessionClient = class {
4758
5664
  * Alt+Shift+R start / stop Alt+Shift+M mark this moment Alt+Shift+D show / hide
4759
5665
  */
4760
5666
  var HIDE_KEY = "saywright.widget.hidden";
5667
+ /** Which side of the page it sits on: moved out of the way of the page's own controls. */
5668
+ var SIDE_KEY = "saywright.widget.side";
4761
5669
  var PREFS_KEY = "saywright.widget.prefs";
4762
5670
  var CSS$1 = `
4763
5671
  :host{all:initial}
@@ -4795,6 +5703,10 @@ input[type=text]:focus{border-color:#0090ff}
4795
5703
  .voice[data-tone=warn] .bars i{background:#ffc53d}.voice[data-tone=warn] .voice-text{color:#ffc53d}
4796
5704
  .voice[data-tone=bad] .voice-text{color:#ff9592}
4797
5705
  .count{font-size:11.5px;color:#a0a0ab;display:flex;gap:8px}.count b{color:#ededef}.count .bad b{color:#ff9592}
5706
+ .root[data-side=left]{right:auto;left:16px}
5707
+ .root[data-side=left] .caption{margin:0 auto 8px 0}
5708
+ .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}
5709
+ .mini .dot{width:8px;height:8px;box-shadow:0 0 0 2px rgba(229,72,77,.25)}
4798
5710
  .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
5711
  .caption .issue{color:#ff9592}
4800
5712
  .err{color:#ff9592;font-size:12px;margin-top:8px}
@@ -4820,7 +5732,9 @@ var ICON = {
4820
5732
  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
5733
  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
5734
  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>"
5735
+ 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>",
5736
+ 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>",
5737
+ 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
5738
  };
4825
5739
  function esc(s) {
4826
5740
  return s.replace(/[&<>"]/g, (c) => ({
@@ -4865,10 +5779,12 @@ var Widget = class {
4865
5779
  reachable = null;
4866
5780
  tick = null;
4867
5781
  hidden;
5782
+ side;
4868
5783
  constructor(endpoint, recorder) {
4869
5784
  this.recorder = recorder;
4870
5785
  this.endpoint = endpoint.replace(/\/$/, "");
4871
5786
  this.hidden = localStorage.getItem(HIDE_KEY) === "1";
5787
+ this.side = localStorage.getItem(SIDE_KEY) === "left" ? "left" : "right";
4872
5788
  this.host = document.createElement("saywright-recorder");
4873
5789
  this.shadow = this.host.attachShadow({ mode: "open" });
4874
5790
  const style = document.createElement("style");
@@ -4914,12 +5830,10 @@ var Widget = class {
4914
5830
  else this.start();
4915
5831
  } else if (e.code === "KeyM") {
4916
5832
  e.preventDefault();
4917
- this.mark();
5833
+ this.mark("shortcut");
4918
5834
  } else if (e.code === "KeyD") {
4919
5835
  e.preventDefault();
4920
- this.hidden = !this.hidden;
4921
- localStorage.setItem(HIDE_KEY, this.hidden ? "1" : "0");
4922
- this.render();
5836
+ this.setHidden(!this.hidden);
4923
5837
  }
4924
5838
  };
4925
5839
  onState(s) {
@@ -4930,6 +5844,13 @@ var Widget = class {
4930
5844
  }
4931
5845
  this.render();
4932
5846
  }
5847
+ /** Hidden: out of the page's way. While recording, a small dot stays, so it is never recording unseen. */
5848
+ setHidden(on) {
5849
+ this.hidden = on;
5850
+ localStorage.setItem(HIDE_KEY, on ? "1" : "0");
5851
+ this.shown = "";
5852
+ this.render();
5853
+ }
4933
5854
  savePrefs() {
4934
5855
  localStorage.setItem(PREFS_KEY, JSON.stringify(this.prefs));
4935
5856
  }
@@ -4942,7 +5863,7 @@ var Widget = class {
4942
5863
  endpoint: this.endpoint,
4943
5864
  name: this.name || void 0,
4944
5865
  lang: this.prefs.lang,
4945
- appUrl: location.href,
5866
+ appUrl: redactUrl(location.href),
4946
5867
  source: "app",
4947
5868
  mic: this.prefs.mic,
4948
5869
  screen: this.prefs.screen,
@@ -4966,15 +5887,27 @@ var Widget = class {
4966
5887
  this.recorder.stop();
4967
5888
  await this.client.stop();
4968
5889
  }
4969
- mark() {
5890
+ /** One mark: the app's recorder logs it, the recording client adds the picture (it was logged twice, once by each). */
5891
+ mark(via = "button") {
4970
5892
  if (this.client.state.status !== "recording") return;
4971
- this.recorder.mark("", "shortcut");
4972
- this.client.mark("", "app");
5893
+ this.recorder.mark("", via);
5894
+ this.client.moment("mark", true);
4973
5895
  }
4974
5896
  render() {
4975
5897
  const s = this.client.state;
4976
- this.root.style.display = this.hidden && s.status !== "recording" && s.status !== "starting" ? "none" : "";
5898
+ const live = s.status === "recording" || s.status === "starting" || s.status === "stopping";
5899
+ this.root.style.display = this.hidden && !live ? "none" : "";
5900
+ this.root.dataset.side = this.side;
4977
5901
  if (s.status === "starting" && s.countdown && !s.id) return this.renderCountdown(s.countdown);
5902
+ if (live && this.hidden) {
5903
+ this.issueUntil = 0;
5904
+ 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>`;
5905
+ if (html === this.shown) return;
5906
+ this.shown = html;
5907
+ this.root.innerHTML = html;
5908
+ this.bind();
5909
+ return;
5910
+ }
4978
5911
  if (s.status === "recording" || s.status === "starting" || s.status === "stopping") return this.renderRecording(s);
4979
5912
  this.shown = "";
4980
5913
  if (s.status === "processing" || s.status === "ready" || s.status === "failed" && s.id) return this.renderDone(s);
@@ -4998,7 +5931,7 @@ var Widget = class {
4998
5931
  <div class="check"><span>Record the screen</span><button class="switch" role="switch" data-toggle="screen" aria-checked="${p.screen}"></button></div>
4999
5932
  <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
5933
  </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>` : ""}
5934
+ ${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
5935
  ${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
5936
  <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
5937
  <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 +5957,8 @@ var Widget = class {
5024
5957
  <button class="btn icon" data-act="mark" title="Mark this moment (Alt+Shift+M)">${ICON.flag}</button>
5025
5958
  <button class="btn icon" data-act="pause" title="${s.paused ? "Resume narration" : "Pause narration"}">${s.paused ? ICON.play : ICON.pause}</button>
5026
5959
  <button class="btn" data-act="stop" ${s.status !== "recording" ? "disabled" : ""}>${ICON.stop}${s.status === "stopping" ? "Saving…" : "Stop"}</button>
5960
+ <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>
5961
+ <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
5962
  </div>`;
5028
5963
  this.issueUntil = issue ? issue.t + 6e3 : 0;
5029
5964
  if (html === this.shown) return;
@@ -5095,7 +6030,7 @@ var Widget = class {
5095
6030
  renderLive() {
5096
6031
  const s = this.client.state;
5097
6032
  if (s.status !== "recording") return;
5098
- if (this.issueUntil && Date.now() > this.issueUntil) return this.renderRecording(s);
6033
+ if (this.issueUntil && Date.now() > this.issueUntil) return this.render();
5099
6034
  const t = clock(Date.now() - s.startedAt);
5100
6035
  if (t !== this.shownTime) {
5101
6036
  const time = this.shadow.querySelector("[data-time]");
@@ -5159,7 +6094,13 @@ var Widget = class {
5159
6094
  else if (act === "mark") this.mark();
5160
6095
  else if (act === "pause") this.client.setPaused(!this.client.state.paused);
5161
6096
  else if (act === "screen") this.client.resumeScreen({ video: true });
5162
- else if (act === "dismiss" || act === "opened") {
6097
+ else if (act === "hide") this.setHidden(true);
6098
+ else if (act === "show") this.setHidden(false);
6099
+ else if (act === "side") {
6100
+ this.side = this.side === "right" ? "left" : "right";
6101
+ localStorage.setItem(SIDE_KEY, this.side);
6102
+ this.render();
6103
+ } else if (act === "dismiss" || act === "opened") {
5163
6104
  this.client.reset();
5164
6105
  this.view = "button";
5165
6106
  this.render();
@@ -5187,6 +6128,18 @@ var Widget = class {
5187
6128
  };
5188
6129
  //#endregion
5189
6130
  //#region ../../packages/vite/src/bridge/experts.ts
6131
+ /**
6132
+ * Checks a page against the rules the studio's experts stand on: Google
6133
+ * Search Central for SEO, WCAG 2.2 (through axe-core) for accessibility,
6134
+ * GOV.UK, web.dev and W3C guidance for content, forms and right-to-left text.
6135
+ * Only what a rule backs is reported, with the elements it is about; judging
6136
+ * quality (is this title good?) is left to the AI review.
6137
+ *
6138
+ * Runs only when asked, inside the page, in short steps with a pause between
6139
+ * them: the page and the studio (same thread) stay responsive, and the studio
6140
+ * shows how far it has got. Long runs of like items (rows, cards) are checked
6141
+ * by their first few.
6142
+ */
5190
6143
  var G = "https://developers.google.com/search/docs";
5191
6144
  /** Lighthouse's link-text list (English), and the Arabic equivalents. */
5192
6145
  var VAGUE_LINKS = /* @__PURE__ */ new Set([
@@ -5314,7 +6267,7 @@ function watchVitals() {
5314
6267
  } catch {}
5315
6268
  }
5316
6269
  function facts() {
5317
- const text = ((document.querySelector("main, [role=\"main\"]") ?? document.body).innerText ?? "").replace(/\n{3,}/g, "\n\n").trim();
6270
+ const text = maskSecrets(((document.querySelector("main, [role=\"main\"]") ?? document.body).innerText ?? "").replace(/\n{3,}/g, "\n\n").trim());
5318
6271
  const here = location.origin;
5319
6272
  let internal = 0;
5320
6273
  let external = 0;
@@ -5336,7 +6289,7 @@ function facts() {
5336
6289
  }
5337
6290
  const fields = Array.from(document.querySelectorAll("input:not([type=hidden]):not([type=submit]):not([type=button]), select, textarea")).filter(shown);
5338
6291
  return {
5339
- url: location.href,
6292
+ url: redactUrl(location.href),
5340
6293
  title: document.title,
5341
6294
  description: meta("description"),
5342
6295
  canonical: document.querySelector("link[rel=\"canonical\"]")?.getAttribute("href") ?? null,
@@ -5361,9 +6314,10 @@ function facts() {
5361
6314
  label: fieldLabel(f),
5362
6315
  type: f.type || f.tagName.toLowerCase(),
5363
6316
  autocomplete: f.getAttribute("autocomplete"),
5364
- required: f.required
6317
+ required: f.required,
6318
+ ...f.getAttribute("inputmode") ? { inputmode: f.getAttribute("inputmode") } : {}
5365
6319
  })),
5366
- buttons: Array.from(document.querySelectorAll("button, [role=\"button\"], input[type=submit]")).filter(shown).map((b) => collapse(b.innerText || b.getAttribute("aria-label") || b.value || "", 40)).filter(Boolean).slice(0, 30),
6320
+ buttons: Array.from(document.querySelectorAll("button, [role=\"button\"], input[type=submit]")).filter(shown).map((b) => collapse(accessibleName(b) || b.value || "", 40) || "(no name)").slice(0, 30),
5367
6321
  lcp,
5368
6322
  cls: watching ? Math.round(cls * 1e3) / 1e3 : null,
5369
6323
  readability: readability(text),
@@ -5405,8 +6359,8 @@ function linkTargets() {
5405
6359
  const out = [];
5406
6360
  for (const a of Array.from(document.querySelectorAll("a[href]")).filter(shown)) {
5407
6361
  const link = {
5408
- text: collapse(a.innerText || a.getAttribute("aria-label") || "", 40),
5409
- href: (a.getAttribute("href") ?? "").slice(0, 120)
6362
+ text: collapse(accessibleName(a), 40) || "(no name)",
6363
+ href: redactUrl(a.getAttribute("href") ?? "").slice(0, 120)
5410
6364
  };
5411
6365
  if (!out.some((l) => l.text === link.text && l.href === link.href)) out.push(link);
5412
6366
  if (out.length >= 25) break;
@@ -6352,10 +7306,17 @@ function start() {
6352
7306
  return;
6353
7307
  case "locator": {
6354
7308
  const el = elOf(m.id);
6355
- return el ? reply(m.reqId, {
7309
+ if (!el) return fail(m.reqId, "gone");
7310
+ if (!m.check) return reply(m.reqId, {
6356
7311
  locator: buildLocator(el),
6357
7312
  info: describe(el)
6358
- }) : fail(m.reqId, "gone");
7313
+ });
7314
+ const target = m.check.state ? controlOf(el) : el;
7315
+ return reply(m.reqId, {
7316
+ locator: buildLocator(target, m.check.state ? {} : { check: m.check.expected }),
7317
+ info: describe(target),
7318
+ ...m.check.state ? stateOf(el, target) : {}
7319
+ });
6359
7320
  }
6360
7321
  case "step": {
6361
7322
  const el = elOf(m.id);
@@ -6416,6 +7377,9 @@ function start() {
6416
7377
  case "record":
6417
7378
  recorder.setOn(m.on);
6418
7379
  return;
7380
+ case "flush":
7381
+ recorder.flushAll();
7382
+ return reply(m.reqId, {});
6419
7383
  case "mouse":
6420
7384
  mouseOn = m.on;
6421
7385
  return;
@@ -6487,21 +7451,35 @@ function start() {
6487
7451
  });
6488
7452
  let mouseOn = false;
6489
7453
  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;
7454
+ let pending = null;
7455
+ let later = null;
7456
+ const sendMouse = () => {
7457
+ later = null;
7458
+ if (!pending || !mouseOn) return;
7459
+ lastMouse = performance.now();
6495
7460
  post({
6496
7461
  type: "mouse",
7462
+ ...pending
7463
+ });
7464
+ pending = null;
7465
+ };
7466
+ window.addEventListener("pointermove", (e) => {
7467
+ if (!mouseOn) return;
7468
+ pending = {
6497
7469
  x: e.clientX,
6498
7470
  y: e.clientY
6499
- });
7471
+ };
7472
+ const wait = 16 - (performance.now() - lastMouse);
7473
+ if (wait <= 0) sendMouse();
7474
+ else later ??= setTimeout(sendMouse, wait);
6500
7475
  }, {
6501
7476
  passive: true,
6502
7477
  capture: true
6503
7478
  });
6504
7479
  document.documentElement.addEventListener("pointerleave", () => {
7480
+ pending = null;
7481
+ if (later) clearTimeout(later);
7482
+ later = null;
6505
7483
  if (mouseOn) post({
6506
7484
  type: "mouse",
6507
7485
  x: null,