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