clearcote 0.47.0 → 0.48.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/README.md +33 -14
- package/dist/cloud.d.ts.map +1 -1
- package/dist/cloud.js +5 -0
- package/dist/cloud.js.map +1 -1
- package/dist/docker.d.ts.map +1 -1
- package/dist/docker.js +5 -1
- package/dist/docker.js.map +1 -1
- package/dist/download.d.ts +2 -0
- package/dist/download.d.ts.map +1 -1
- package/dist/download.js +2 -1
- package/dist/download.js.map +1 -1
- package/dist/forms.d.ts +198 -0
- package/dist/forms.d.ts.map +1 -0
- package/dist/forms.js +632 -0
- package/dist/forms.js.map +1 -0
- package/dist/humanize.d.ts +37 -0
- package/dist/humanize.d.ts.map +1 -1
- package/dist/humanize.js +1207 -323
- package/dist/humanize.js.map +1 -1
- package/dist/index.d.ts +19 -9
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +26 -12
- package/dist/index.js.map +1 -1
- package/dist/isolated.d.ts +71 -3
- package/dist/isolated.d.ts.map +1 -1
- package/dist/isolated.js +226 -26
- package/dist/isolated.js.map +1 -1
- package/dist/widevine.d.ts +90 -5
- package/dist/widevine.d.ts.map +1 -1
- package/dist/widevine.js +391 -40
- package/dist/widevine.js.map +1 -1
- package/package.json +1 -1
package/dist/forms.js
ADDED
|
@@ -0,0 +1,632 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure planning for humanized form input (port of sdk/python/clearcote/_forms.py).
|
|
3
|
+
*
|
|
4
|
+
* Nothing here touches a page: it decides WHAT a person would press for a form control, and
|
|
5
|
+
* ./humanize.ts decides HOW (glides, holds, cadence). Keeping the decisions pure is what lets one set
|
|
6
|
+
* of test vectors (sdk/testdata/humanize_forms_vectors.json) pin Python (_forms.py), Node (this file)
|
|
7
|
+
* and .NET (Forms.cs) to identical answers.
|
|
8
|
+
*
|
|
9
|
+
* What a person does, measured on r37 headless (SC 197 forms probes, 2026-10-11):
|
|
10
|
+
* - <select> (a drop-down): a click opens the popup; letters typed while it is open move the
|
|
11
|
+
* popup's highlight and change NOTHING on the page; Enter commits -> exactly one trusted
|
|
12
|
+
* input + change. Typing on a focused but CLOSED select instead fires one change per letter, and
|
|
13
|
+
* stepping it with arrows fires one change per row -- the old route, which picked row 40 with 40
|
|
14
|
+
* change events.
|
|
15
|
+
* - list boxes (size > 1 or multiple): a click on a row selects it, Ctrl+click adds a row.
|
|
16
|
+
* - <input type=range>: a click on the track jumps to that fraction, arrows step by `step`.
|
|
17
|
+
* - date / time / month / week / datetime-local: the editor's segments come in the BROWSER's
|
|
18
|
+
* order (month/day/year, hour/minute/ampm on these builds, whatever the persona's locale), so the
|
|
19
|
+
* order is read from the editor itself, never assumed. Numeric segments take digits and move on
|
|
20
|
+
* after a full field; the year never moves on by itself (ArrowRight does); a month NAME field
|
|
21
|
+
* (type=month) ignores digits and steps on arrows; the ampm field takes A / P.
|
|
22
|
+
*/
|
|
23
|
+
// ----------------------------------------------------------------------------- field classes
|
|
24
|
+
// The fill() routes. Playwright's own split (kInputTypesToSetValue / kInputTypesToTypeInto):
|
|
25
|
+
// text-like types are typed into, value types have their value set, everything else raises.
|
|
26
|
+
export const TEXT_TYPES = new Set(["", "text", "search", "email", "password", "tel", "url", "number"]);
|
|
27
|
+
export const DATETIME_TYPES = new Set(["date", "time", "month", "week", "datetime-local"]);
|
|
28
|
+
// color: Playwright sets it; the only humane route is the OS colour dialog, which no page sees and
|
|
29
|
+
// no script can drive -- so native, exactly as Playwright behaves.
|
|
30
|
+
export const NATIVE_VALUE_TYPES = new Set(["color"]);
|
|
31
|
+
/** 'text' | 'range' | 'datetime' | 'native' | 'unfillable' for one element.
|
|
32
|
+
*
|
|
33
|
+
* 'native' = let Playwright set the value (its semantics, untrusted but correct);
|
|
34
|
+
* 'unfillable' = let Playwright's fill raise exactly as it would have (checkbox, file, ...). */
|
|
35
|
+
export function classifyField(tag, inputType, contenteditable = false) {
|
|
36
|
+
const t0 = (tag || "").toLowerCase();
|
|
37
|
+
if (contenteditable && t0 !== "input" && t0 !== "select")
|
|
38
|
+
return "text";
|
|
39
|
+
if (t0 === "textarea")
|
|
40
|
+
return "text";
|
|
41
|
+
if (t0 !== "input")
|
|
42
|
+
return "unfillable";
|
|
43
|
+
const t = (inputType || "").toLowerCase();
|
|
44
|
+
if (TEXT_TYPES.has(t))
|
|
45
|
+
return "text";
|
|
46
|
+
if (t === "range")
|
|
47
|
+
return "range";
|
|
48
|
+
if (DATETIME_TYPES.has(t))
|
|
49
|
+
return "datetime";
|
|
50
|
+
if (NATIVE_VALUE_TYPES.has(t))
|
|
51
|
+
return "native";
|
|
52
|
+
// An unknown type is a text input to the browser (HTML: invalid type -> text).
|
|
53
|
+
if (!["checkbox", "radio", "file", "submit", "button", "image", "reset", "hidden"].includes(t))
|
|
54
|
+
return "text";
|
|
55
|
+
return "unfillable";
|
|
56
|
+
}
|
|
57
|
+
// ----------------------------------------------------------------------------------- typos
|
|
58
|
+
// No fat-finger typos where a wrong character is not a harmless slip but a validation failure or a
|
|
59
|
+
// locked field: credentials, numbers, addresses, one-time codes.
|
|
60
|
+
export const NO_TYPO_TYPES = new Set(["password", "email", "number", "tel", "url"]);
|
|
61
|
+
export const NO_TYPO_INPUTMODES = new Set(["numeric", "decimal", "tel", "email", "url"]);
|
|
62
|
+
export const NO_TYPO_AUTOCOMPLETE = new Set(["one-time-code", "cc-number", "cc-csc", "cc-exp",
|
|
63
|
+
"current-password", "new-password"]);
|
|
64
|
+
// Compact QWERTY adjacency for letter typos; digits slip to a NEIGHBOURING DIGIT on the number row
|
|
65
|
+
// (a digit never turns into a letter, and never into itself).
|
|
66
|
+
export const NEARBY_LETTERS = {
|
|
67
|
+
a: "sqwz", b: "vghn", c: "xdfv", d: "sfecx", e: "wrsdf", f: "dgrtcv",
|
|
68
|
+
g: "fhtyb", h: "gjybn", i: "ujko", j: "hkunm", k: "jloi", l: "kop",
|
|
69
|
+
m: "njk", n: "bhjm", o: "iklp", p: "ol", q: "wa", r: "edft",
|
|
70
|
+
s: "awedxz", t: "rfgy", u: "yhji", v: "cfgb", w: "qase", x: "zsdc",
|
|
71
|
+
y: "tghu", z: "asx",
|
|
72
|
+
};
|
|
73
|
+
export const NEARBY_DIGITS = {
|
|
74
|
+
"1": "2", "2": "13", "3": "24", "4": "35", "5": "46", "6": "57", "7": "68",
|
|
75
|
+
"8": "79", "9": "80", "0": "9",
|
|
76
|
+
};
|
|
77
|
+
export const DEFAULT_TYPO_RATE = 0.02;
|
|
78
|
+
/** May a fat-finger typo happen in this field? Unknown fields allow typos, as the old behaviour did;
|
|
79
|
+
* a field inside a frame (unreadable from here) fails CLOSED. */
|
|
80
|
+
export function typosAllowed(field) {
|
|
81
|
+
if (!field || Object.keys(field).length === 0)
|
|
82
|
+
return true;
|
|
83
|
+
if (field.frame)
|
|
84
|
+
return false; // focus is inside a frame we cannot read: fail closed
|
|
85
|
+
const t = (field.type || "").toLowerCase();
|
|
86
|
+
if (NO_TYPO_TYPES.has(t))
|
|
87
|
+
return false;
|
|
88
|
+
if (NO_TYPO_INPUTMODES.has((field.inputmode || "").toLowerCase()))
|
|
89
|
+
return false;
|
|
90
|
+
const ac = (field.autocomplete || "").toLowerCase().split(/\s+/).filter(Boolean);
|
|
91
|
+
return !ac.some((tok) => NO_TYPO_AUTOCOMPLETE.has(tok));
|
|
92
|
+
}
|
|
93
|
+
/** Characters a typo may replace: unshifted ASCII letters and digits. */
|
|
94
|
+
export function typoEligible(ch) {
|
|
95
|
+
return typeof ch === "string" && ch.length === 1 && ((ch >= "a" && ch <= "z") || (ch >= "0" && ch <= "9"));
|
|
96
|
+
}
|
|
97
|
+
const choice = (s, rng) => s[Math.floor(rng() * s.length)];
|
|
98
|
+
/** A neighbouring key for `ch` (same class: letter -> letter, digit -> digit), or null. */
|
|
99
|
+
export function nearbyKey(ch, rng = Math.random) {
|
|
100
|
+
if (ch.length === 1 && ch >= "a" && ch <= "z")
|
|
101
|
+
return choice(NEARBY_LETTERS[ch], rng);
|
|
102
|
+
if (ch.length === 1 && ch >= "0" && ch <= "9")
|
|
103
|
+
return choice(NEARBY_DIGITS[ch], rng);
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
/** Decide whether character `i` of `text` gets a typo, and which kind.
|
|
107
|
+
*
|
|
108
|
+
* null, or:
|
|
109
|
+
* {kind: "sub", wrong, late} -- a neighbouring key instead of text[i]; noticed after `late` (0-2)
|
|
110
|
+
* more correct characters, then Backspace late+1 times and retype.
|
|
111
|
+
* {kind: "double"} -- text[i] pressed twice; one Backspace.
|
|
112
|
+
* {kind: "swap"} -- text[i+1] typed before text[i]; two Backspaces.
|
|
113
|
+
* `text` is indexed by code point (pass a string or an Array.from(text) array). The humanize layer
|
|
114
|
+
* still checks that each wrong key actually landed in the field before it deletes anything (an input
|
|
115
|
+
* mask or a maxlength can swallow it). */
|
|
116
|
+
export function planTypo(text, i, rng = Math.random, rate = DEFAULT_TYPO_RATE) {
|
|
117
|
+
const t = typeof text === "string" ? Array.from(text) : text;
|
|
118
|
+
const ch = t[i];
|
|
119
|
+
if (!typoEligible(ch) || rate <= 0 || rng() >= rate)
|
|
120
|
+
return null;
|
|
121
|
+
const r = rng();
|
|
122
|
+
const nxt = i + 1 < t.length ? t[i + 1] : null;
|
|
123
|
+
if (r < 0.15 && nxt !== null && typoEligible(nxt) && nxt !== ch)
|
|
124
|
+
return { kind: "swap" };
|
|
125
|
+
if (r < 0.30)
|
|
126
|
+
return { kind: "double" };
|
|
127
|
+
let late = 0;
|
|
128
|
+
if (rng() < 0.3) {
|
|
129
|
+
// Late notice: only over characters that are themselves plain unshifted keys, so the retyping
|
|
130
|
+
// after the Backspaces stays on the simple path.
|
|
131
|
+
const k = rng() < 0.7 ? 1 : 2;
|
|
132
|
+
const tail = t.slice(i + 1, i + 1 + k);
|
|
133
|
+
if (tail.length === k && tail.every((c) => typoEligible(c) || c === " "))
|
|
134
|
+
late = k;
|
|
135
|
+
}
|
|
136
|
+
return { kind: "sub", wrong: nearbyKey(ch, rng), late };
|
|
137
|
+
}
|
|
138
|
+
/** Python's str.isalnum() for one code point (Unicode letters and numbers). */
|
|
139
|
+
const isAlnum = (c) => /^[\p{L}\p{N}]$/u.test(c);
|
|
140
|
+
/** Is `got` just `want` re-punctuated by an input mask -- the same letters and digits in the same
|
|
141
|
+
* order, with separators, spaces or case changed ("(555) 123-4567" for "5551234567")? A value only a
|
|
142
|
+
* formatter changed counts as typed right; a lost or an extra key does not. Typing it again would not
|
|
143
|
+
* change what the formatter does, and no person types their phone number twice. */
|
|
144
|
+
export function formattedEqual(got, want) {
|
|
145
|
+
if (got === null || got === undefined || want === null || want === undefined)
|
|
146
|
+
return false;
|
|
147
|
+
const a = Array.from(got).filter(isAlnum).map(casefold);
|
|
148
|
+
const b = Array.from(want).filter(isAlnum).map(casefold);
|
|
149
|
+
return b.length > 0 && a.length === b.length && a.every((c, i) => c === b[i]);
|
|
150
|
+
}
|
|
151
|
+
// ------------------------------------------------------------------------------ select-all
|
|
152
|
+
/** The select-all chord a person on `platform` uses: Cmd+A on a Mac, Ctrl+A elsewhere. */
|
|
153
|
+
export function selectAllCombo(platform) {
|
|
154
|
+
return ["macos", "mac", "darwin"].includes((platform || "").toLowerCase()) ? "Meta+a" : "Control+a";
|
|
155
|
+
}
|
|
156
|
+
const MODIFIERS = ["Alt", "Control", "ControlOrMeta", "Meta", "Shift"];
|
|
157
|
+
/** Playwright click/dblclick/hover options -> what the humanized press needs.
|
|
158
|
+
*
|
|
159
|
+
* The old wrappers forwarded only `timeout`, so `button: "right"` became a left click, modifiers and
|
|
160
|
+
* `position` were dropped, and `trial: true` -- documented to perform NO action -- clicked.
|
|
161
|
+
*
|
|
162
|
+
* "ControlOrMeta" is kept as is: Playwright resolves it to the key the ENGINE's platform uses, which is
|
|
163
|
+
* what makes Ctrl+click open a link in a new tab on a Windows or Linux build whatever the persona
|
|
164
|
+
* claims. (`platform` is accepted for API symmetry and unused.) Both `clickCount` and the Python
|
|
165
|
+
* spelling `click_count` are read. */
|
|
166
|
+
export function normalizeClickOptions(options, _platform) {
|
|
167
|
+
const o = options || {};
|
|
168
|
+
let button = o.button || "left";
|
|
169
|
+
if (button !== "left" && button !== "right" && button !== "middle")
|
|
170
|
+
button = "left";
|
|
171
|
+
const raw = "click_count" in o ? o.click_count : o.clickCount;
|
|
172
|
+
let count = raw ? Math.trunc(Number(raw)) : 1;
|
|
173
|
+
if (!Number.isFinite(count))
|
|
174
|
+
count = 1;
|
|
175
|
+
const mods = [];
|
|
176
|
+
for (const m of (o.modifiers || [])) {
|
|
177
|
+
if (MODIFIERS.includes(m) && !mods.includes(m))
|
|
178
|
+
mods.push(m);
|
|
179
|
+
}
|
|
180
|
+
const pos = o.position;
|
|
181
|
+
const position = pos && typeof pos === "object" && "x" in pos && "y" in pos
|
|
182
|
+
? { x: Number(pos.x), y: Number(pos.y) } : null;
|
|
183
|
+
let delay = o.delay !== undefined && o.delay !== null ? pyFloat(o.delay) : null;
|
|
184
|
+
// delay 0 is Playwright's DEFAULT ("no hold"), not a request for a 1 ms press: it, and anything
|
|
185
|
+
// non-positive or non-finite, means "use the persona's hold" (a 1-4 ms button hold is a tell, and a
|
|
186
|
+
// negative hold would leave the button down).
|
|
187
|
+
if (delay !== null && (!Number.isFinite(delay) || delay <= 0))
|
|
188
|
+
delay = null;
|
|
189
|
+
return {
|
|
190
|
+
button, clickCount: Math.max(1, count), modifiers: mods,
|
|
191
|
+
delay, position,
|
|
192
|
+
force: Boolean(o.force), trial: Boolean(o.trial),
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
/** The engine pointer path performs one plain left click and nothing else. */
|
|
196
|
+
export function enginePathOk(o) {
|
|
197
|
+
return o.button === "left" && o.clickCount === 1 && o.modifiers.length === 0 && o.delay === null;
|
|
198
|
+
}
|
|
199
|
+
// ------------------------------------------------------------------- <select> type-ahead plan
|
|
200
|
+
/** Python's str.casefold(), closely enough for option labels: full upper-casing then lower-casing
|
|
201
|
+
* folds what lower-casing alone misses (sharp s -> ss, ligatures, final sigma). */
|
|
202
|
+
function casefold(s) {
|
|
203
|
+
return s.toUpperCase().toLowerCase();
|
|
204
|
+
}
|
|
205
|
+
/** Blink compares FoldCase()d labels with leading whitespace stripped (type_ahead.cc). */
|
|
206
|
+
function fold(s) {
|
|
207
|
+
return casefold((s || "").replace(/^\s+/, ""));
|
|
208
|
+
}
|
|
209
|
+
// A space counts: inside a running type-ahead search Blink appends it like any other character, so
|
|
210
|
+
// "item 371" lands on "Item 371" in a 400-row list (measured on r37 by verifier B: 1 trusted change,
|
|
211
|
+
// 0.99 s). Without it the prefix stopped at "item" and the other 370 rows were ArrowDowns (68 s). It
|
|
212
|
+
// is never the FIRST key: labels are compared with leading whitespace stripped, and a space that
|
|
213
|
+
// starts a search can open or close the popup instead.
|
|
214
|
+
export const TYPEABLE = new Set("abcdefghijklmnopqrstuvwxyz0123456789 ");
|
|
215
|
+
/** Where Blink's TypeAhead (kMatchPrefix | kCycleFirstChar) lands after `keys`.
|
|
216
|
+
*
|
|
217
|
+
* `labels`: one entry per option, null for a disabled option (Blink's OptionAtIndex returns an empty
|
|
218
|
+
* string for those, so they never match). `start`: the highlighted index when typing begins (the
|
|
219
|
+
* current selection; -1 = none). Returns the landing index (unchanged if no match).
|
|
220
|
+
*
|
|
221
|
+
* Mirrors type_ahead.cc: a first key, or the same key again, searches for that ONE character starting
|
|
222
|
+
* AFTER the current item (so a repeated letter cycles through the items that start with it); a mixed
|
|
223
|
+
* buffer searches for the whole buffer starting AT the current item. */
|
|
224
|
+
export function typeaheadSimulate(labels, start, keys) {
|
|
225
|
+
const n = labels.length;
|
|
226
|
+
if (n === 0)
|
|
227
|
+
return start;
|
|
228
|
+
const folded = labels.map((l) => (l !== null && l !== undefined ? fold(l) : null));
|
|
229
|
+
let buf = "";
|
|
230
|
+
let repeating = null;
|
|
231
|
+
let cur = start;
|
|
232
|
+
for (const c0 of Array.from(keys)) {
|
|
233
|
+
const c = casefold(c0);
|
|
234
|
+
buf += c;
|
|
235
|
+
let offset = 1;
|
|
236
|
+
let prefix;
|
|
237
|
+
if (c === repeating) {
|
|
238
|
+
prefix = c;
|
|
239
|
+
}
|
|
240
|
+
else {
|
|
241
|
+
prefix = buf;
|
|
242
|
+
if (Array.from(buf).length > 1) {
|
|
243
|
+
repeating = null;
|
|
244
|
+
offset = 0;
|
|
245
|
+
}
|
|
246
|
+
else {
|
|
247
|
+
repeating = c;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
let idx = ((cur >= 0 ? cur : 0) + offset) % n;
|
|
251
|
+
for (let k = 0; k < n; k++) {
|
|
252
|
+
const lab = folded[idx];
|
|
253
|
+
if (lab !== null && lab !== "" && lab.startsWith(prefix)) {
|
|
254
|
+
cur = idx;
|
|
255
|
+
break;
|
|
256
|
+
}
|
|
257
|
+
idx = (idx + 1) % n;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return cur;
|
|
261
|
+
}
|
|
262
|
+
/** Arrow presses from row `frm` to row `to` in the popup, which skips disabled rows (signed). Each
|
|
263
|
+
* press lands on the next enabled row, so the count is the enabled rows passed through INCLUDING the
|
|
264
|
+
* target: (frm, to] going down, [to, frm) going up -- the start row itself never counts, which is what
|
|
265
|
+
* makes a move away from a disabled current row come out right. */
|
|
266
|
+
function enabledSteps(labels, frm, to) {
|
|
267
|
+
if (frm === to)
|
|
268
|
+
return 0;
|
|
269
|
+
const on = (i) => labels[i] !== null && labels[i] !== undefined;
|
|
270
|
+
if (!on(to))
|
|
271
|
+
return null;
|
|
272
|
+
let steps = 0;
|
|
273
|
+
if (to > frm) {
|
|
274
|
+
for (let i = frm + 1; i <= to; i++)
|
|
275
|
+
if (on(i))
|
|
276
|
+
steps++;
|
|
277
|
+
return steps;
|
|
278
|
+
}
|
|
279
|
+
for (let i = to; i < frm; i++)
|
|
280
|
+
if (on(i))
|
|
281
|
+
steps++;
|
|
282
|
+
return -steps; // the target counts, so never -0
|
|
283
|
+
}
|
|
284
|
+
/** Keys that move an OPEN drop-down's highlight from `current` to `target`.
|
|
285
|
+
*
|
|
286
|
+
* Returns {keys: "<letters to type>", arrows: <signed ArrowDown count>} with the fewest presses: the
|
|
287
|
+
* shortest typeable prefix of the target's label that type-ahead lands on exactly, else the cheapest
|
|
288
|
+
* (prefix, then arrows) combination, else arrows alone. null when the target is disabled (no route
|
|
289
|
+
* selects it). */
|
|
290
|
+
export function planSelect(labels, current, target, maxPrefix = 24) {
|
|
291
|
+
if (!(target >= 0 && target < labels.length) || labels[target] === null || labels[target] === undefined)
|
|
292
|
+
return null;
|
|
293
|
+
let best = null;
|
|
294
|
+
const start = current >= 0 && current < labels.length ? current : -1;
|
|
295
|
+
const base = start >= 0 ? enabledSteps(labels, start, target) : null;
|
|
296
|
+
if (base !== null) {
|
|
297
|
+
best = { keys: "", arrows: base };
|
|
298
|
+
}
|
|
299
|
+
else if (start < 0) {
|
|
300
|
+
// No selection: the popup highlights the first row; arrows count from there.
|
|
301
|
+
const first = labels.findIndex((l) => l !== null && l !== undefined);
|
|
302
|
+
if (first >= 0) {
|
|
303
|
+
const st = enabledSteps(labels, first, target);
|
|
304
|
+
if (st !== null)
|
|
305
|
+
best = { keys: "", arrows: st };
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
const label = Array.from(fold(labels[target]));
|
|
309
|
+
let prefix = "";
|
|
310
|
+
for (const ch of label.slice(0, Math.max(0, maxPrefix))) {
|
|
311
|
+
if (!TYPEABLE.has(ch) || (ch === " " && !prefix))
|
|
312
|
+
break;
|
|
313
|
+
prefix += ch;
|
|
314
|
+
const land = typeaheadSimulate(labels, start, prefix);
|
|
315
|
+
if (land < 0 || labels[land] === null || labels[land] === undefined)
|
|
316
|
+
continue;
|
|
317
|
+
const st = enabledSteps(labels, land, target);
|
|
318
|
+
if (st === null)
|
|
319
|
+
continue;
|
|
320
|
+
const cost = prefix.length + Math.abs(st);
|
|
321
|
+
const bestCost = best === null ? null : best.keys.length + Math.abs(best.arrows);
|
|
322
|
+
// fewest presses; on a tie, typing the name outright beats typing and then arrowing
|
|
323
|
+
if (best === null || cost < bestCost || (cost === bestCost && st === 0 && best.arrows !== 0)) {
|
|
324
|
+
best = { keys: prefix, arrows: st };
|
|
325
|
+
}
|
|
326
|
+
if (st === 0)
|
|
327
|
+
break;
|
|
328
|
+
}
|
|
329
|
+
return best;
|
|
330
|
+
}
|
|
331
|
+
/** Playwright's option matching for ONE wanted option: a string `value` matches an option's value OR
|
|
332
|
+
* its label; `label` matches the label; `index` the position. Returns the index or -1. */
|
|
333
|
+
export function matchOption(options, want) {
|
|
334
|
+
const { value, index, label } = want;
|
|
335
|
+
if (index !== undefined && index !== null)
|
|
336
|
+
return index >= 0 && index < options.length ? index : -1;
|
|
337
|
+
for (let i = 0; i < options.length; i++) {
|
|
338
|
+
const o = options[i];
|
|
339
|
+
if (label !== undefined && label !== null) {
|
|
340
|
+
if (o.label === label)
|
|
341
|
+
return i;
|
|
342
|
+
}
|
|
343
|
+
else if (value !== undefined && value !== null && (o.value === value || o.label === value)) {
|
|
344
|
+
return i;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
return -1;
|
|
348
|
+
}
|
|
349
|
+
// --------------------------------------------------------------------------------- range
|
|
350
|
+
const FLOAT_RE = /^[+-]?(?:\d[\d_]*(?:\.[\d_]*)?|\.\d[\d_]*)(?:[eE][+-]?\d[\d_]*)?$/;
|
|
351
|
+
/** Python's float(v) for a string or number: null where float() would raise. */
|
|
352
|
+
export function pyFloat(v) {
|
|
353
|
+
if (typeof v === "number")
|
|
354
|
+
return v;
|
|
355
|
+
if (typeof v === "boolean")
|
|
356
|
+
return v ? 1 : 0;
|
|
357
|
+
if (typeof v !== "string")
|
|
358
|
+
return null;
|
|
359
|
+
const s = v.trim();
|
|
360
|
+
const low = s.toLowerCase().replace(/^[+-]/, "");
|
|
361
|
+
if (low === "inf" || low === "infinity")
|
|
362
|
+
return s.startsWith("-") ? -Infinity : Infinity;
|
|
363
|
+
if (low === "nan")
|
|
364
|
+
return NaN;
|
|
365
|
+
if (!FLOAT_RE.test(s) || /__|_\.|\._|_$|_[eE]|[eE]_/.test(s))
|
|
366
|
+
return null;
|
|
367
|
+
return Number(s.replace(/_/g, ""));
|
|
368
|
+
}
|
|
369
|
+
function num(v, dflt) {
|
|
370
|
+
const f = pyFloat(v);
|
|
371
|
+
return f === null || Number.isNaN(f) ? dflt : f;
|
|
372
|
+
}
|
|
373
|
+
/** HTML defaults: min 0, max 100, step 1 ('any' = no snapping, null); max below min -> max = min. */
|
|
374
|
+
export function rangeAttrs(min, max, step) {
|
|
375
|
+
const lo = num(min, 0.0);
|
|
376
|
+
let hi = num(max, 100.0);
|
|
377
|
+
if (hi < lo)
|
|
378
|
+
hi = lo;
|
|
379
|
+
let st;
|
|
380
|
+
if (typeof step === "string" && step.trim().toLowerCase() === "any") {
|
|
381
|
+
st = null;
|
|
382
|
+
}
|
|
383
|
+
else {
|
|
384
|
+
st = num(step, 1.0);
|
|
385
|
+
if (st <= 0)
|
|
386
|
+
st = 1.0;
|
|
387
|
+
}
|
|
388
|
+
return [lo, hi, st];
|
|
389
|
+
}
|
|
390
|
+
/** The value the browser will hold after being asked for `value` (clamped, step-aligned from min,
|
|
391
|
+
* rounded to the nearest step; a tie goes up, as Blink's StepRange does). */
|
|
392
|
+
export function rangeSanitize(value, min, max, step) {
|
|
393
|
+
const [lo, hi, st] = rangeAttrs(min, max, step);
|
|
394
|
+
let v = Math.min(Math.max(num(value, (lo + hi) / 2), lo), hi);
|
|
395
|
+
if (st) {
|
|
396
|
+
// Blink steps in Decimal; the epsilon keeps binary floats from rounding 0.35/0.1 =
|
|
397
|
+
// 3.4999999999999996 down to 3 where the browser gets 4. The step count is capped at the last
|
|
398
|
+
// step inside max -- computed, never walked down in a loop: a page can serve max=123456789
|
|
399
|
+
// step=1e-9, where subtracting one step leaves the float unchanged and the loop never ends
|
|
400
|
+
// (which froze the whole event loop).
|
|
401
|
+
const q = (v - lo) / st, last = (hi - lo) / st;
|
|
402
|
+
if (!(Number.isFinite(q) && Number.isFinite(last)))
|
|
403
|
+
return v; // a step too small to count in floats
|
|
404
|
+
const n = Math.min(Math.floor(q + 0.5 + 1e-9), Math.floor(last + 1e-9));
|
|
405
|
+
v = lo + n * st;
|
|
406
|
+
}
|
|
407
|
+
return v;
|
|
408
|
+
}
|
|
409
|
+
/** Would the browser keep `value` exactly? Playwright's fill sets the value and raises "Malformed
|
|
410
|
+
* value" when the browser changed it (off the step, out of range, not a number), so the humane route
|
|
411
|
+
* only takes values that pass and leaves the rest to Playwright to reject. */
|
|
412
|
+
export function rangeValueOk(value, min, max, step) {
|
|
413
|
+
const f = pyFloat(value);
|
|
414
|
+
if (f === null || !Number.isFinite(f))
|
|
415
|
+
return false;
|
|
416
|
+
return numericEqual(rangeSanitize(f, min, max, step), f);
|
|
417
|
+
}
|
|
418
|
+
export function rangeFraction(value, min, max) {
|
|
419
|
+
const [lo, hi] = rangeAttrs(min, max, null);
|
|
420
|
+
return hi === lo ? 0.0 : (value - lo) / (hi - lo);
|
|
421
|
+
}
|
|
422
|
+
// Chromium's default slider thumb is ~16 px wide; the track's usable span is inset by half of it.
|
|
423
|
+
export const RANGE_THUMB_INSET = 8.0;
|
|
424
|
+
/** Where on a horizontal range input the thumb sits at `fraction` (CSS px): [x, y]. */
|
|
425
|
+
export function rangePoint(box, fraction, rtl = false, inset = RANGE_THUMB_INSET) {
|
|
426
|
+
const w = box.width;
|
|
427
|
+
const ins = Math.min(inset, w / 2.0);
|
|
428
|
+
const f = rtl ? 1.0 - fraction : fraction;
|
|
429
|
+
return [box.x + ins + f * (w - 2 * ins), box.y + box.height / 2.0];
|
|
430
|
+
}
|
|
431
|
+
/** Python's round(): half to even. */
|
|
432
|
+
function roundHalfEven(x) {
|
|
433
|
+
const f = Math.floor(x);
|
|
434
|
+
const d = x - f;
|
|
435
|
+
if (d > 0.5)
|
|
436
|
+
return f + 1;
|
|
437
|
+
if (d < 0.5)
|
|
438
|
+
return f;
|
|
439
|
+
return f % 2 === 0 ? f : f + 1;
|
|
440
|
+
}
|
|
441
|
+
/** Arrow presses from `current` to `target`: ['ArrowRight'|'ArrowLeft', count] or [null, 0].
|
|
442
|
+
* ArrowRight raises a horizontal LTR range by one step (and lowers an RTL one; the caller swaps). */
|
|
443
|
+
export function rangeNudge(current, target, step) {
|
|
444
|
+
if (step === null || step === undefined || step <= 0 || Math.abs(target - current) < 1e-9)
|
|
445
|
+
return [null, 0];
|
|
446
|
+
const n = roundHalfEven(Math.abs(target - current) / step);
|
|
447
|
+
return [target > current ? "ArrowRight" : "ArrowLeft", n];
|
|
448
|
+
}
|
|
449
|
+
export function numericEqual(a, b) {
|
|
450
|
+
const fa = pyFloat(a), fb = pyFloat(b);
|
|
451
|
+
if (fa === null || fb === null)
|
|
452
|
+
return false;
|
|
453
|
+
return Math.abs(fa - fb) < 1e-6;
|
|
454
|
+
}
|
|
455
|
+
/** Parse an HTML date-like value into its parts, or null if it is not valid for `kind`. */
|
|
456
|
+
export function parseDatetimeValue(kind, value) {
|
|
457
|
+
const v = (value || "").trim();
|
|
458
|
+
if (kind === "date") {
|
|
459
|
+
const m = /^(\d{4,6})-(\d{2})-(\d{2})$/.exec(v);
|
|
460
|
+
if (!m)
|
|
461
|
+
return null;
|
|
462
|
+
const y = parseInt(m[1], 10), mo = parseInt(m[2], 10), d = parseInt(m[3], 10);
|
|
463
|
+
return mo >= 1 && mo <= 12 && d >= 1 && d <= 31 ? { year: y, month: mo, day: d } : null;
|
|
464
|
+
}
|
|
465
|
+
if (kind === "month") {
|
|
466
|
+
const m = /^(\d{4,6})-(\d{2})$/.exec(v);
|
|
467
|
+
if (!m)
|
|
468
|
+
return null;
|
|
469
|
+
const y = parseInt(m[1], 10), mo = parseInt(m[2], 10);
|
|
470
|
+
return mo >= 1 && mo <= 12 ? { year: y, month: mo } : null;
|
|
471
|
+
}
|
|
472
|
+
if (kind === "week") {
|
|
473
|
+
const m = /^(\d{4,6})-W(\d{2})$/.exec(v);
|
|
474
|
+
if (!m)
|
|
475
|
+
return null;
|
|
476
|
+
const y = parseInt(m[1], 10), w = parseInt(m[2], 10);
|
|
477
|
+
return w >= 1 && w <= 53 ? { year: y, week: w } : null;
|
|
478
|
+
}
|
|
479
|
+
if (kind === "time")
|
|
480
|
+
return parseTime(v);
|
|
481
|
+
if (kind === "datetime-local") {
|
|
482
|
+
const m = /^(\d{4,6})-(\d{2})-(\d{2})[T ](.+)$/.exec(v);
|
|
483
|
+
if (!m)
|
|
484
|
+
return null;
|
|
485
|
+
const t = parseTime(m[4]);
|
|
486
|
+
const y = parseInt(m[1], 10), mo = parseInt(m[2], 10), d = parseInt(m[3], 10);
|
|
487
|
+
if (t === null || !(mo >= 1 && mo <= 12 && d >= 1 && d <= 31))
|
|
488
|
+
return null;
|
|
489
|
+
return { ...t, year: y, month: mo, day: d };
|
|
490
|
+
}
|
|
491
|
+
return null;
|
|
492
|
+
}
|
|
493
|
+
function parseTime(v) {
|
|
494
|
+
const m = /^(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,3}))?)?$/.exec(v);
|
|
495
|
+
if (!m)
|
|
496
|
+
return null;
|
|
497
|
+
const h = parseInt(m[1], 10), mi = parseInt(m[2], 10);
|
|
498
|
+
const s = m[3] ? parseInt(m[3], 10) : 0;
|
|
499
|
+
const ms = m[4] ? parseInt(m[4].padEnd(3, "0"), 10) : 0;
|
|
500
|
+
if (h > 23 || mi > 59 || s > 59)
|
|
501
|
+
return null;
|
|
502
|
+
return { hour: h, minute: mi, second: s, millisecond: ms };
|
|
503
|
+
}
|
|
504
|
+
function sameParts(a, b) {
|
|
505
|
+
const ka = Object.keys(a).sort(), kb = Object.keys(b).sort();
|
|
506
|
+
return ka.length === kb.length && ka.every((k, i) => k === kb[i] && a[k] === b[k]);
|
|
507
|
+
}
|
|
508
|
+
/** Same moment for `kind`, whatever the formatting (17:45 == 17:45:00). */
|
|
509
|
+
export function datetimeEqual(kind, a, b) {
|
|
510
|
+
const pa = parseDatetimeValue(kind, a), pb = parseDatetimeValue(kind, b);
|
|
511
|
+
return pa !== null && pb !== null && sameParts(pa, pb);
|
|
512
|
+
}
|
|
513
|
+
const pad = (n, w) => String(n).padStart(w, "0");
|
|
514
|
+
/** Key actions that type `value` into a native date-like editor whose fields come in `segments`
|
|
515
|
+
* order (names as the editor's pseudo elements give them, '-field' optional: month, day, year, hour,
|
|
516
|
+
* minute, second, millisecond, ampm, week).
|
|
517
|
+
*
|
|
518
|
+
* null when the editor cannot represent the value (a seconds value with no seconds field, a year
|
|
519
|
+
* beyond four digits, an unknown segment). */
|
|
520
|
+
export function planDatetime(kind, segments, value, current) {
|
|
521
|
+
const parts = parseDatetimeValue(kind, value);
|
|
522
|
+
if (parts === null)
|
|
523
|
+
return null;
|
|
524
|
+
const segs = segments.map((s) => s.split("-field").join(""));
|
|
525
|
+
if (segs.length === 0)
|
|
526
|
+
return null;
|
|
527
|
+
const haveAmpm = segs.includes("ampm");
|
|
528
|
+
if (kind === "time" || kind === "datetime-local") {
|
|
529
|
+
if (parts.second && !segs.includes("second"))
|
|
530
|
+
return null;
|
|
531
|
+
if (parts.millisecond && !segs.includes("millisecond"))
|
|
532
|
+
return null;
|
|
533
|
+
}
|
|
534
|
+
const cur = current ? parseDatetimeValue(kind, current) : null;
|
|
535
|
+
const out = [];
|
|
536
|
+
for (let k = 0; k < segs.length; k++) {
|
|
537
|
+
const seg = segs[k];
|
|
538
|
+
const last = k === segs.length - 1;
|
|
539
|
+
if (seg === "year") {
|
|
540
|
+
const y = parts.year;
|
|
541
|
+
if (y === undefined || y > 9999)
|
|
542
|
+
return null;
|
|
543
|
+
out.push(["type", pad(y, 4)]);
|
|
544
|
+
if (!last)
|
|
545
|
+
out.push(["key", "ArrowRight"]); // the year never moves on by itself
|
|
546
|
+
}
|
|
547
|
+
else if (seg === "month") {
|
|
548
|
+
const m = parts.month;
|
|
549
|
+
if (m === undefined)
|
|
550
|
+
return null;
|
|
551
|
+
if (kind === "month") {
|
|
552
|
+
// A month NAME field: digits are ignored; it steps on arrows. From empty the first ArrowUp
|
|
553
|
+
// shows January.
|
|
554
|
+
const c = cur ? cur.month : undefined;
|
|
555
|
+
if (c === undefined) {
|
|
556
|
+
out.push(["key", "ArrowUp", m]);
|
|
557
|
+
}
|
|
558
|
+
else {
|
|
559
|
+
const d = (((m - c) % 12) + 12) % 12;
|
|
560
|
+
out.push(d <= 6 ? ["key", "ArrowUp", d] : ["key", "ArrowDown", 12 - d]);
|
|
561
|
+
}
|
|
562
|
+
if (!last)
|
|
563
|
+
out.push(["key", "ArrowRight"]);
|
|
564
|
+
}
|
|
565
|
+
else {
|
|
566
|
+
out.push(["type", pad(m, 2)]);
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
else if (seg === "day") {
|
|
570
|
+
const d = parts.day;
|
|
571
|
+
if (d === undefined)
|
|
572
|
+
return null;
|
|
573
|
+
out.push(["type", pad(d, 2)]);
|
|
574
|
+
}
|
|
575
|
+
else if (seg === "week") {
|
|
576
|
+
const w = parts.week;
|
|
577
|
+
if (w === undefined)
|
|
578
|
+
return null;
|
|
579
|
+
out.push(["type", pad(w, 2)]);
|
|
580
|
+
}
|
|
581
|
+
else if (seg === "hour") {
|
|
582
|
+
const h = parts.hour;
|
|
583
|
+
if (h === undefined)
|
|
584
|
+
return null;
|
|
585
|
+
out.push(["type", pad(haveAmpm ? (h % 12 || 12) : h, 2)]);
|
|
586
|
+
}
|
|
587
|
+
else if (seg === "minute") {
|
|
588
|
+
out.push(["type", pad(parts.minute ?? 0, 2)]);
|
|
589
|
+
}
|
|
590
|
+
else if (seg === "second") {
|
|
591
|
+
out.push(["type", pad(parts.second ?? 0, 2)]);
|
|
592
|
+
}
|
|
593
|
+
else if (seg === "millisecond") {
|
|
594
|
+
out.push(["type", pad(parts.millisecond ?? 0, 3)]);
|
|
595
|
+
}
|
|
596
|
+
else if (seg === "ampm") {
|
|
597
|
+
// lower case: a capital with no Shift held is input no keyboard produces
|
|
598
|
+
out.push(["type", (parts.hour ?? 0) < 12 ? "a" : "p"]);
|
|
599
|
+
}
|
|
600
|
+
else {
|
|
601
|
+
return null;
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
// drop no-op arrow steps (a month already right)
|
|
605
|
+
return out.filter((a) => !(a[0] === "key" && a.length === 3 && a[2] === 0));
|
|
606
|
+
}
|
|
607
|
+
function walkSegments(node, out) {
|
|
608
|
+
const attrs = node.attributes || [];
|
|
609
|
+
for (let j = 0; j + 1 < attrs.length; j += 2) {
|
|
610
|
+
if (attrs[j] === "pseudo") {
|
|
611
|
+
const ps = attrs[j + 1];
|
|
612
|
+
if (ps.startsWith("-webkit-datetime-edit-") && ps.endsWith("-field")) {
|
|
613
|
+
out.push([ps.slice("-webkit-datetime-edit-".length), node.backendNodeId]);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
for (const c of [...(node.shadowRoots || []), ...(node.children || [])])
|
|
618
|
+
walkSegments(c, out);
|
|
619
|
+
}
|
|
620
|
+
/** The date-like editor's field names, in order, from a CDP DOM.describeNode(pierce) tree. */
|
|
621
|
+
export function segmentNamesFromTree(node) {
|
|
622
|
+
const out = [];
|
|
623
|
+
walkSegments(node, out);
|
|
624
|
+
return out.map(([name]) => name);
|
|
625
|
+
}
|
|
626
|
+
/** Like segmentNamesFromTree but with each field's backendNodeId: [[name, backendNodeId]]. */
|
|
627
|
+
export function segmentNodesFromTree(node) {
|
|
628
|
+
const out = [];
|
|
629
|
+
walkSegments(node, out);
|
|
630
|
+
return out;
|
|
631
|
+
}
|
|
632
|
+
//# sourceMappingURL=forms.js.map
|