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