@saywright/vite 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.
- package/dist/bridge.js +1188 -210
- package/dist/index.js +47 -0
- package/dist/protocol.d.ts +57 -5
- package/dist/protocol.js +333 -5
- package/dist/redact.d.ts +62 -0
- package/package.json +1 -1
package/dist/bridge.js
CHANGED
|
@@ -1,9 +1,326 @@
|
|
|
1
|
-
//#region src/
|
|
1
|
+
//#region src/redact.ts
|
|
2
|
+
/**
|
|
3
|
+
* What is never recorded: passwords, one-time codes, keys and tokens, card and bank numbers. The bridge's two
|
|
4
|
+
* recorders (test sessions, and Write test), the studio (for what an older plugin still sends), the tests it writes
|
|
5
|
+
* and what it sends an AI all go by these rules, so a secret is caught the same way wherever it would show.
|
|
6
|
+
*
|
|
7
|
+
* A field is secret by what it is (a password field, a field someone marked private) or by what it is called (its
|
|
8
|
+
* name, id, autocomplete, label or placeholder: “Password”, “cc-number”, “API key”, “رمز التحقق”). A value is secret
|
|
9
|
+
* by its look alone (a card number, a key or a token), whatever its field is called. A test types a secret from the
|
|
10
|
+
* environment (SAYWRIGHT_SECRET_PASSWORD), never from its own file.
|
|
11
|
+
*/
|
|
12
|
+
/** Shown in place of a secret. */
|
|
13
|
+
var MASK = "•••";
|
|
14
|
+
/** A value as it may be shown: a dot for each character, at most twelve, so its length is a hint and nothing more. */
|
|
15
|
+
function dots(value) {
|
|
16
|
+
return "•".repeat(Math.min(value.length, 12));
|
|
17
|
+
}
|
|
18
|
+
/** A name, id or label as plain words: “cardNumber”, “card_number”, “cc-number” → “card number”, “cc number”. */
|
|
19
|
+
function words$1(text) {
|
|
20
|
+
return text.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/[^\p{L}\p{N}]+/gu, " ").trim().toLowerCase();
|
|
21
|
+
}
|
|
22
|
+
/** Words that make a field secret, whatever else it is called. */
|
|
23
|
+
var SECRET_WORDS = new RegExp("\\b(?:" + [
|
|
24
|
+
"pass(?:word|wd|code|phrase|key)s?",
|
|
25
|
+
"pwd",
|
|
26
|
+
"pw",
|
|
27
|
+
"secrets?",
|
|
28
|
+
"tokens?",
|
|
29
|
+
"api ?keys?",
|
|
30
|
+
"access ?keys?",
|
|
31
|
+
"secret ?keys?",
|
|
32
|
+
"private ?keys?",
|
|
33
|
+
"client ?secrets?",
|
|
34
|
+
"licen[cs]e ?keys?",
|
|
35
|
+
"product ?keys?",
|
|
36
|
+
"activation ?(?:keys?|codes?)",
|
|
37
|
+
"recovery ?(?:keys?|codes?)",
|
|
38
|
+
"backup ?codes?",
|
|
39
|
+
"auth(?:entication|orization)? ?codes?",
|
|
40
|
+
"otp",
|
|
41
|
+
"totp",
|
|
42
|
+
"one ?time ?(?:codes?|passwords?|passcodes?|pins?)",
|
|
43
|
+
"(?:verification|security|confirmation|sms|2fa|mfa) ?codes?",
|
|
44
|
+
"2fa",
|
|
45
|
+
"mfa",
|
|
46
|
+
"cvv2?",
|
|
47
|
+
"cvc2?",
|
|
48
|
+
"csc",
|
|
49
|
+
"card ?(?:numbers?|num|no|codes?|verification|holder|security|expiry|expiration|exp|cvc|cvv)",
|
|
50
|
+
"name ?on ?card",
|
|
51
|
+
"credit ?cards?",
|
|
52
|
+
"debit ?cards?",
|
|
53
|
+
"cc ?(?:number|num|no|exp|expiry|csc|cvc|cvv|name|given|family|type)",
|
|
54
|
+
"iban",
|
|
55
|
+
"account ?(?:numbers?|num|no)",
|
|
56
|
+
"routing ?(?:numbers?|num|no)",
|
|
57
|
+
"sort ?codes?",
|
|
58
|
+
"ssn",
|
|
59
|
+
"social ?security",
|
|
60
|
+
"tax ?id",
|
|
61
|
+
"national ?id",
|
|
62
|
+
"passports?",
|
|
63
|
+
"pins?(?: ?codes?)?",
|
|
64
|
+
"(?:recovery|seed|secret|backup|mnemonic) ?(?:phrases?|words?)",
|
|
65
|
+
"mnemonics?",
|
|
66
|
+
"(?:security|secret) ?(?:questions?|answers?)",
|
|
67
|
+
"memorable ?(?:words?|information|info)",
|
|
68
|
+
"mother s maiden name"
|
|
69
|
+
].join("|") + ")\\b");
|
|
70
|
+
/** The same in Arabic (password, secret code, verification code, card number, account number). */
|
|
71
|
+
var SECRET_WORDS_AR = /كلمة (?:ال)?(?:مرور|سر)|(?:ال)?رمز (?:ال)?(?:سري|تحقق|التحقق|أمان|الأمان|المرور)|رقم (?:ال)?(?:بطاقة|حساب|سري)|آيبان/;
|
|
72
|
+
/** Whether a field described so (its type, name, id, autocomplete, label and placeholder, in one text) holds a secret. */
|
|
73
|
+
function isSecretHint(hint) {
|
|
74
|
+
return SECRET_WORDS.test(words$1(hint)) || SECRET_WORDS_AR.test(hint);
|
|
75
|
+
}
|
|
76
|
+
/** Words a field's label adds that don't tell one secret from another. */
|
|
77
|
+
var FILLER = /* @__PURE__ */ new Set([
|
|
78
|
+
"enter",
|
|
79
|
+
"your",
|
|
80
|
+
"the",
|
|
81
|
+
"a",
|
|
82
|
+
"an",
|
|
83
|
+
"please",
|
|
84
|
+
"type",
|
|
85
|
+
"confirm",
|
|
86
|
+
"confirmation",
|
|
87
|
+
"repeat",
|
|
88
|
+
"again",
|
|
89
|
+
"re",
|
|
90
|
+
"retype",
|
|
91
|
+
"field",
|
|
92
|
+
"here",
|
|
93
|
+
"of",
|
|
94
|
+
"required",
|
|
95
|
+
"optional"
|
|
96
|
+
]);
|
|
97
|
+
/** A word before a secret that does tell it from another: a new password isn't the current one. */
|
|
98
|
+
var QUALIFIER = /* @__PURE__ */ new Set([
|
|
99
|
+
"new",
|
|
100
|
+
"old",
|
|
101
|
+
"current",
|
|
102
|
+
"previous",
|
|
103
|
+
"admin",
|
|
104
|
+
"master",
|
|
105
|
+
"root"
|
|
106
|
+
]);
|
|
107
|
+
/**
|
|
108
|
+
* What a secret is called in a test, from its field's label (or name): “Enter your password” → PASSWORD, “Card
|
|
109
|
+
* number” → CARD_NUMBER, “New password” → NEW_PASSWORD, “Password (8 characters or more)” → PASSWORD; “Confirm
|
|
110
|
+
* password” is PASSWORD too, so one value fills both.
|
|
111
|
+
*/
|
|
112
|
+
function secretName(label) {
|
|
113
|
+
const w = words$1(label);
|
|
114
|
+
const m = SECRET_WORDS.exec(w);
|
|
115
|
+
let name;
|
|
116
|
+
if (m) {
|
|
117
|
+
const before = w.slice(0, m.index).trim().split(" ").pop() ?? "";
|
|
118
|
+
name = [...QUALIFIER.has(before) ? [before] : [], ...m[0].split(" ")];
|
|
119
|
+
} else name = w.split(" ").filter((x) => x && !FILLER.has(x) && /^[a-z0-9]+$/.test(x)).slice(0, 3);
|
|
120
|
+
if (name.length) return name.join("_").toUpperCase().slice(0, 40);
|
|
121
|
+
if (/كلمة (?:ال)?(?:مرور|سر)/.test(label)) return "PASSWORD";
|
|
122
|
+
if (/تحقق|التحقق/.test(label)) return "VERIFICATION_CODE";
|
|
123
|
+
if (/بطاقة/.test(label)) return "CARD_NUMBER";
|
|
124
|
+
return "SECRET";
|
|
125
|
+
}
|
|
126
|
+
/** What may be a card number: digits with spaces or dashes between them (isCard decides). */
|
|
127
|
+
var CARD = /\b\d(?:[ -]?\d){12,22}\b/g;
|
|
128
|
+
/** Keys and tokens by their shape. */
|
|
129
|
+
var TOKENS = [
|
|
130
|
+
/\beyJ[\w-]{6,}\.eyJ[\w-]{6,}\.[\w-]{6,}/g,
|
|
131
|
+
/\b(?:sk|rk)-[\w-]{16,}/g,
|
|
132
|
+
/\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{10,}/g,
|
|
133
|
+
/\bgh[pousr]_[A-Za-z0-9]{20,}/g,
|
|
134
|
+
/\bgithub_pat_[A-Za-z0-9_]{20,}/g,
|
|
135
|
+
/\bglpat-[\w-]{16,}/g,
|
|
136
|
+
/\bxox[abprs]-[A-Za-z0-9-]{10,}/g,
|
|
137
|
+
/\bnpm_[A-Za-z0-9]{30,}/g,
|
|
138
|
+
/\bAKIA[0-9A-Z]{16}\b/g,
|
|
139
|
+
/\bAIza[0-9A-Za-z_-]{35}/g,
|
|
140
|
+
/\bwhsec_[A-Za-z0-9]{16,}/g,
|
|
141
|
+
/\bSG\.[\w-]{16,}\.[\w-]{16,}/g,
|
|
142
|
+
/(?<=\b(?:aws_?secret_?access_?key|secret_?access_?key|client_?secret|api_?secret|private_?key|access_?token|refresh_?token|auth_?token|password|passwd)["']?\s*[:=]\s*["']?)[^\s"',;}{]{8,}/gi,
|
|
143
|
+
/\b[a-z]{2,8}_(?:(?:live|test)_)?(?=[A-Za-z0-9]{20,}\b)(?=[A-Za-z0-9]*\d)(?=[A-Za-z0-9]*[a-z])(?=[A-Za-z0-9]*[A-Z])[A-Za-z0-9]{20,}\b/g,
|
|
144
|
+
/\bBearer\s+[A-Za-z0-9._~+/-]{12,}=*/g,
|
|
145
|
+
/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z ]*PRIVATE KEY-----|$)/g
|
|
146
|
+
];
|
|
147
|
+
function luhn(digits) {
|
|
148
|
+
let sum = 0;
|
|
149
|
+
for (let i = 0; i < digits.length; i++) {
|
|
150
|
+
let d = Number(digits[digits.length - 1 - i]);
|
|
151
|
+
if (i % 2) d = d * 2 > 9 ? d * 2 - 9 : d * 2;
|
|
152
|
+
sum += d;
|
|
153
|
+
}
|
|
154
|
+
return sum % 10 === 0;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Whether digits are written as a card number is: 13 to 19 of them, starting with 2 to 6 (as cards of every network
|
|
158
|
+
* do), passing the Luhn check, and either unbroken or in groups of 4 to 6 (the last one shorter) with one kind of
|
|
159
|
+
* separator. Not a date with a number either side of it, an order row or a timestamp.
|
|
160
|
+
*/
|
|
161
|
+
function isCard(text) {
|
|
162
|
+
const d = text.replace(/\D/g, "");
|
|
163
|
+
if (d.length < 13 || d.length > 19 || !/^[2-6]/.test(d) || !luhn(d)) return false;
|
|
164
|
+
if (/^\d+$/.test(text)) return true;
|
|
165
|
+
if (text.includes(" ") && text.includes("-")) return false;
|
|
166
|
+
const groups = text.split(/[ -]/);
|
|
167
|
+
return groups.every((g) => /^\d+$/.test(g)) && groups.slice(0, -1).every((g) => g.length >= 4 && g.length <= 6) && groups[groups.length - 1].length <= 6;
|
|
168
|
+
}
|
|
169
|
+
/** A card number in `run` (digits with spaces or dashes), with what follows it kept: the longest first part that is one. */
|
|
170
|
+
function maskCard(run) {
|
|
171
|
+
const parts = run.split(/(?<=\d)(?=[ -])/);
|
|
172
|
+
for (let k = parts.length; k > 0; k--) {
|
|
173
|
+
const head = parts.slice(0, k).join("");
|
|
174
|
+
if (isCard(head)) return `•••• ${head.replace(/\D/g, "").slice(-4)}${parts.slice(k).join("")}`;
|
|
175
|
+
}
|
|
176
|
+
return run;
|
|
177
|
+
}
|
|
178
|
+
/** Text with every card number (kept: its last four digits) and every key- or token-shaped string in it masked. */
|
|
179
|
+
function maskSecrets(text) {
|
|
180
|
+
if (!text) return text;
|
|
181
|
+
let out = text.replace(CARD, maskCard);
|
|
182
|
+
out = out.replace(/\b([a-z][a-z0-9+.-]*:\/\/[^\s:@/]+:)([^\s@/]+)@/gi, (_m, head) => `${head}${MASK}@`);
|
|
183
|
+
for (const re of TOKENS) out = out.replace(re, (m) => m.startsWith("Bearer") ? `Bearer ${MASK}` : MASK);
|
|
184
|
+
return out;
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Whether a typed value is a secret by its look alone: a card number, a key or a token, or a long random string (of
|
|
188
|
+
* letters in both cases and digits, as keys are; or 32 hexadecimal digits or more). A slug, a sentence, an email
|
|
189
|
+
* address or a coupon code is not.
|
|
190
|
+
*/
|
|
191
|
+
function looksSecret(value) {
|
|
192
|
+
const v = value.trim();
|
|
193
|
+
if (v.length < 12) return false;
|
|
194
|
+
if (maskSecrets(v) !== v) return true;
|
|
195
|
+
if (/^[a-f0-9]{32,}$/i.test(v) && /\d/.test(v) && /[a-f]/i.test(v)) return true;
|
|
196
|
+
return /^[A-Za-z0-9+/_=-]{24,}$/.test(v) && /\d/.test(v) && /[a-z]/.test(v) && /[A-Z]/.test(v);
|
|
197
|
+
}
|
|
198
|
+
/** Query and hash parameters whose value is a secret by their name alone. */
|
|
199
|
+
var SECRET_PARAM = /^(?:access_?token|refresh_?token|id_?token|auth_?token|token|auth|authorization|api_?key|apikey|key|secret|client_?secret|password|passwd|pwd|pass|otp|code_?verifier|jwt|session|session_?id|sessionid|sid|signature|sig|x-amz-signature|x-amz-credential|x-amz-security-token|x-goog-signature|x-goog-credential)$/i;
|
|
200
|
+
/** Addresses of sign-in steps, where `code`, `state` and `nonce` carry what lets someone in. */
|
|
201
|
+
var AUTH_PATH = /(?:^|\/)(?:callback|oauth2?|auth|authorize|sso|login|signin|sign-in|verify|magic|reset|confirm)(?:[/?#.]|$)/i;
|
|
202
|
+
/** Whether a page's path is a sign-in step's (/login, /verify, /auth/callback…): a code asked for there lets someone in. */
|
|
203
|
+
function isAuthPath(pathname) {
|
|
204
|
+
return AUTH_PATH.test(pathname);
|
|
205
|
+
}
|
|
206
|
+
/** Codes that are not secrets: a test needs them as typed (SAVE20, a postal code, a colour). */
|
|
207
|
+
var PLAIN_CODE = /\b(?:coupon|promo|promotion|discount|voucher|gift|referral|invite|invitation|postal|post|zip|area|country|dialling|dialing|product|sku|item|colou?r|tracking|order|booking|reference|ref|course|class|group|room|seat|ticket|event|campaign|source|language|currency|qr|bar|barcode|html|css|snippet|embed|promotional)\b/;
|
|
208
|
+
/**
|
|
209
|
+
* Whether a field described so asks for a code that lets someone in, by its look: called a code or a PIN (“Code”,
|
|
210
|
+
* “Enter the 6-digit code we emailed you”), not a coupon or a postal code, and short and numeric (its input mode,
|
|
211
|
+
* type, pattern or length), or asked for on a sign-in step's page.
|
|
212
|
+
*/
|
|
213
|
+
function isCodeField(hint, field, pathname = "") {
|
|
214
|
+
const w = words$1(hint);
|
|
215
|
+
if (!/\b(?:code|codes|pin|passcode)\b/.test(w) || PLAIN_CODE.test(w)) return false;
|
|
216
|
+
return field.numeric || field.shortMax || isAuthPath(pathname);
|
|
217
|
+
}
|
|
218
|
+
function decoded(s) {
|
|
219
|
+
try {
|
|
220
|
+
return decodeURIComponent(s.replace(/\+/g, " "));
|
|
221
|
+
} catch {
|
|
222
|
+
return s;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
/** `a=1&b=2` with the values of secret parameters masked, and key- or token-shaped values too. */
|
|
226
|
+
function maskParams(params, authPath) {
|
|
227
|
+
return params.split("&").map((pair) => {
|
|
228
|
+
const eq = pair.indexOf("=");
|
|
229
|
+
if (eq < 0) return maskSecrets(pair);
|
|
230
|
+
const name = decoded(pair.slice(0, eq));
|
|
231
|
+
const value = pair.slice(eq + 1);
|
|
232
|
+
if (!value) return pair;
|
|
233
|
+
const secret = SECRET_PARAM.test(name) || isSecretHint(name) || authPath && /^(?:code|state|nonce)$/i.test(name) || looksSecret(decoded(value));
|
|
234
|
+
return `${pair.slice(0, eq)}=${secret ? MASK : maskSecrets(value)}`;
|
|
235
|
+
}).join("&");
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* An address with the secrets in it masked: the values of parameters named like a token, key, password or session
|
|
239
|
+
* (in its query, and in its hash: `#access_token=…`), sign-in codes, and key- or token-shaped parts of its path.
|
|
240
|
+
* Works on whole and relative addresses, and never throws.
|
|
241
|
+
*/
|
|
242
|
+
function redactUrl(url) {
|
|
243
|
+
if (!url) return url;
|
|
244
|
+
const hashAt = url.indexOf("#");
|
|
245
|
+
const base = hashAt < 0 ? url : url.slice(0, hashAt);
|
|
246
|
+
const hash = hashAt < 0 ? null : url.slice(hashAt + 1);
|
|
247
|
+
const q = base.indexOf("?");
|
|
248
|
+
const path = q < 0 ? base : base.slice(0, q);
|
|
249
|
+
const authPath = AUTH_PATH.test(path);
|
|
250
|
+
let out = maskSecrets(path).replace(/(?<=\/)[A-Za-z0-9_-]{24,}(?=\/|$)/g, (part) => /\d/.test(part) && /[a-z]/.test(part) && /[A-Z]/.test(part) ? MASK : part);
|
|
251
|
+
if (q >= 0) out += `?${maskParams(base.slice(q + 1), authPath)}`;
|
|
252
|
+
if (hash !== null) {
|
|
253
|
+
const hq = hash.indexOf("?");
|
|
254
|
+
if (hq >= 0) out += `#${maskSecrets(hash.slice(0, hq))}?${maskParams(hash.slice(hq + 1), authPath || AUTH_PATH.test(hash.slice(0, hq)))}`;
|
|
255
|
+
else out += `#${hash.includes("=") ? maskParams(hash, authPath) : maskSecrets(hash)}`;
|
|
256
|
+
}
|
|
257
|
+
return out;
|
|
258
|
+
}
|
|
259
|
+
/** A recorded element's words, masked: text it showed may hold a card number or a key. */
|
|
260
|
+
function scrubTarget(el) {
|
|
261
|
+
if (!el || typeof el !== "object") return;
|
|
262
|
+
const t = el;
|
|
263
|
+
if (typeof t.label === "string") t.label = maskSecrets(t.label);
|
|
264
|
+
if (typeof t.text === "string") t.text = maskSecrets(t.text);
|
|
265
|
+
if (typeof t.description === "string") t.description = maskSecrets(t.description);
|
|
266
|
+
}
|
|
2
267
|
/**
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
268
|
+
* A session event as it may be kept: addresses with their secrets masked (see redactUrl), card numbers and keys
|
|
269
|
+
* masked in every text it carries (errors, console lines, response bodies, what the screen said), and a typed value
|
|
270
|
+
* that is a secret (by its field's name, or its look) as dots. The bridge records events so; the studio scrubs
|
|
271
|
+
* what arrives again, for events an older plugin recorded. Changes the event in place, and returns it.
|
|
6
272
|
*/
|
|
273
|
+
function scrubEvent(event) {
|
|
274
|
+
const e = event;
|
|
275
|
+
const url = (k) => {
|
|
276
|
+
if (typeof e[k] === "string") e[k] = redactUrl(e[k]);
|
|
277
|
+
};
|
|
278
|
+
const text = (k) => {
|
|
279
|
+
if (typeof e[k] === "string") e[k] = maskSecrets(e[k]);
|
|
280
|
+
};
|
|
281
|
+
scrubTarget(e.el);
|
|
282
|
+
switch (e.type) {
|
|
283
|
+
case "page":
|
|
284
|
+
url("url");
|
|
285
|
+
url("referrer");
|
|
286
|
+
break;
|
|
287
|
+
case "nav":
|
|
288
|
+
url("url");
|
|
289
|
+
break;
|
|
290
|
+
case "net":
|
|
291
|
+
url("url");
|
|
292
|
+
text("body");
|
|
293
|
+
text("error");
|
|
294
|
+
break;
|
|
295
|
+
case "error":
|
|
296
|
+
case "console":
|
|
297
|
+
text("message");
|
|
298
|
+
text("text");
|
|
299
|
+
text("stack");
|
|
300
|
+
break;
|
|
301
|
+
case "dom":
|
|
302
|
+
url("url");
|
|
303
|
+
text("text");
|
|
304
|
+
if (Array.isArray(e.headings)) e.headings = e.headings.map((h) => typeof h === "string" ? maskSecrets(h) : h);
|
|
305
|
+
break;
|
|
306
|
+
case "input": {
|
|
307
|
+
const target = e.el ?? {};
|
|
308
|
+
const value = typeof e.value === "string" ? e.value : "";
|
|
309
|
+
if (!e.masked && (isSecretHint(`${target.label ?? ""} ${target.name ?? ""}`) || looksSecret(value))) {
|
|
310
|
+
e.value = dots(value);
|
|
311
|
+
e.masked = true;
|
|
312
|
+
e.secret ??= secretName(target.label || target.name || "");
|
|
313
|
+
} else if (!e.masked) text("value");
|
|
314
|
+
break;
|
|
315
|
+
}
|
|
316
|
+
case "change":
|
|
317
|
+
text("value");
|
|
318
|
+
if (Array.isArray(e.options)) e.options = e.options.map((o) => typeof o === "string" ? maskSecrets(o) : o);
|
|
319
|
+
}
|
|
320
|
+
return event;
|
|
321
|
+
}
|
|
322
|
+
//#endregion
|
|
323
|
+
//#region src/protocol.ts
|
|
7
324
|
var NS = "saywright";
|
|
8
325
|
/** Which side has to be updated for a studio and an app's bridge to work together: the app's plugin, the studio, or neither. */
|
|
9
326
|
function outdated(studio, bridge) {
|
|
@@ -11,7 +328,14 @@ function outdated(studio, bridge) {
|
|
|
11
328
|
if (studio.v < (bridge.oldest ?? bridge.v)) return "studio";
|
|
12
329
|
return null;
|
|
13
330
|
}
|
|
14
|
-
|
|
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,
|
|
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(
|
|
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
|
|
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
|
|
978
|
+
if (wrapping) parts.push(labelWords(wrapping));
|
|
548
979
|
return collapse(parts.join(" "), 60);
|
|
549
980
|
}
|
|
550
|
-
|
|
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,
|
|
988
|
+
if (aria) return collapse(aria, NAME_MAX);
|
|
554
989
|
const labelledBy = el.getAttribute("aria-labelledby");
|
|
555
|
-
if (labelledBy) return collapse(textOfIds(labelledBy),
|
|
556
|
-
if (isField(el)) return labelText(el) || collapse(el.getAttribute("title"),
|
|
557
|
-
if (el instanceof HTMLImageElement) return collapse(el.alt,
|
|
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,
|
|
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
|
|
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
|
|
576
|
-
|
|
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:
|
|
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 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 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\"],
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
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,
|
|
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 &&
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
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 &&
|
|
1117
|
-
|
|
1118
|
-
|
|
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 (
|
|
1123
|
-
|
|
1124
|
-
|
|
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
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
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
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
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
|
-
|
|
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 src/bridge/explore.ts
|
|
@@ -1175,20 +1813,39 @@ function fieldOf(el) {
|
|
|
1175
1813
|
};
|
|
1176
1814
|
return {
|
|
1177
1815
|
type,
|
|
1178
|
-
value:
|
|
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
|
|
1345
|
-
*
|
|
1346
|
-
*
|
|
1347
|
-
*
|
|
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),
|
|
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 ?? "",
|
|
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") || "",
|
|
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") ?? "",
|
|
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,
|
|
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
|
-
/**
|
|
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
|
-
|
|
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 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)
|
|
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
|
|
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:
|
|
2023
|
-
|
|
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
|
|
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
|
|
2056
|
-
description: `Type “${
|
|
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 src/bridge/
|
|
2860
|
+
//#region src/bridge/proxy.ts
|
|
2065
2861
|
/**
|
|
2066
|
-
*
|
|
2067
|
-
*
|
|
2068
|
-
*
|
|
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 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
|
-
|
|
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
|
-
|
|
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",
|
|
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(
|
|
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 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 =
|
|
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 ?
|
|
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
|
|
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
|
-
|
|
4147
|
+
const at = now();
|
|
4148
|
+
const url = location.href;
|
|
4149
|
+
setTimeout(() => this.active && this.record({
|
|
3261
4150
|
type: "nav",
|
|
3262
|
-
url
|
|
4151
|
+
url,
|
|
3263
4152
|
title: document.title,
|
|
3264
4153
|
how
|
|
3265
|
-
},
|
|
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 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
|
|
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
|
-
/**
|
|
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
|
|
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.
|
|
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("",
|
|
4972
|
-
this.client.
|
|
5893
|
+
this.recorder.mark("", via);
|
|
5894
|
+
this.client.moment("mark", true);
|
|
4973
5895
|
}
|
|
4974
5896
|
render() {
|
|
4975
5897
|
const s = this.client.state;
|
|
4976
|
-
|
|
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>
|
|
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.
|
|
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 === "
|
|
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 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
|
|
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
|
|
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
|
-
|
|
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
|
-
})
|
|
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
|
-
|
|
6491
|
-
|
|
6492
|
-
|
|
6493
|
-
|
|
6494
|
-
|
|
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,
|