@geometra/proxy 1.45.0 → 1.47.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/dom-actions.d.ts +1 -0
- package/dist/dom-actions.d.ts.map +1 -1
- package/dist/dom-actions.js +351 -38
- package/dist/dom-actions.js.map +1 -1
- package/dist/geometry-ws.d.ts.map +1 -1
- package/dist/geometry-ws.js +34 -2
- package/dist/geometry-ws.js.map +1 -1
- package/package.json +1 -1
package/dist/dom-actions.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Locator, Page } from 'playwright';
|
|
2
2
|
import type { ClientChoiceType, ClientFillField } from './types.js';
|
|
3
|
+
export declare function delay(ms: number): Promise<void>;
|
|
3
4
|
export type FileAttachStrategy = 'auto' | 'chooser' | 'hidden' | 'drop';
|
|
4
5
|
export interface FillLookupCache {
|
|
5
6
|
control: Map<string, Locator | null>;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"dom-actions.d.ts","sourceRoot":"","sources":["../src/dom-actions.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAwB,OAAO,EAAE,IAAI,EAAE,MAAM,YAAY,CAAA;AACrE,OAAO,KAAK,EAAE,gBAAgB,EAAE,eAAe,EAAE,MAAM,YAAY,CAAA;
|
|
1
|
+
{"version":3,"file":"dom-actions.d.ts","sourceRoot":"","sources":["../src/dom-actions.ts"],"names":[],"mappings":"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"}
|
package/dist/dom-actions.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs';
|
|
2
2
|
import { resolve } from 'node:path';
|
|
3
|
-
function delay(ms) {
|
|
3
|
+
export function delay(ms) {
|
|
4
4
|
return new Promise(r => setTimeout(r, ms));
|
|
5
5
|
}
|
|
6
6
|
const LABELED_CONTROL_SELECTOR = 'input, select, textarea, button, [role="combobox"], [role="textbox"], [aria-haspopup="listbox"], [contenteditable="true"]';
|
|
@@ -1873,11 +1873,22 @@ async function pressEnterToCommitListbox(page, handle) {
|
|
|
1873
1873
|
* no form ancestor, etc.) — "unknown" is NOT a veto, same convention as
|
|
1874
1874
|
* `readAriaInvalid`.
|
|
1875
1875
|
*/
|
|
1876
|
-
async function readFormLevelInvalidState(handle) {
|
|
1876
|
+
async function readFormLevelInvalidState(handle, opts) {
|
|
1877
1877
|
if (!handle)
|
|
1878
1878
|
return false;
|
|
1879
|
+
// Default behavior preserves the existing veto logic: a blank required
|
|
1880
|
+
// hidden input only counts as invalid when SOMETHING else in the wrapper
|
|
1881
|
+
// also flags an error (role=alert / data-invalid / data-error). That's
|
|
1882
|
+
// safe for read-only checks where we don't know whether a commit was
|
|
1883
|
+
// recently attempted. When `requireWrapperFlag: false`, the function
|
|
1884
|
+
// becomes stricter and treats a blank required hidden input as
|
|
1885
|
+
// definitively-not-committed even without a visible error flag — the
|
|
1886
|
+
// caller (postCommitVerify) is asserting that a commit was just attempted,
|
|
1887
|
+
// so the hidden input's emptiness IS authoritative. Bug surfaced by
|
|
1888
|
+
// JobForge round-2 marathon — Airtable PM AI #94 phone-country combobox.
|
|
1889
|
+
const requireWrapperFlag = opts?.requireWrapperFlag ?? true;
|
|
1879
1890
|
try {
|
|
1880
|
-
return await handle.evaluate((el) => {
|
|
1891
|
+
return await handle.evaluate((el, requireWrapperFlagFn) => {
|
|
1881
1892
|
if (!(el instanceof Element))
|
|
1882
1893
|
return false;
|
|
1883
1894
|
// Find the closest <form>. No form = can't verify, not a veto.
|
|
@@ -1923,9 +1934,15 @@ async function readFormLevelInvalidState(handle) {
|
|
|
1923
1934
|
// hidden input is blank. react-select writes an empty string to
|
|
1924
1935
|
// its hidden input when uncommitted.
|
|
1925
1936
|
if (input.required && (input.value === '' || input.value == null)) {
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1937
|
+
if (!requireWrapperFlagFn) {
|
|
1938
|
+
// Caller (postCommitVerify) just attempted a commit; a blank
|
|
1939
|
+
// required hidden input is authoritative even without a
|
|
1940
|
+
// visible error flag.
|
|
1941
|
+
return true;
|
|
1942
|
+
}
|
|
1943
|
+
// Read-only mode: only veto if SOMETHING in the wrapper also
|
|
1944
|
+
// flags the field as in-error. A blank hidden input on a page
|
|
1945
|
+
// that hasn't attempted submit yet is common and non-authoritative.
|
|
1929
1946
|
const flaggedInWrapper = wrapper.querySelector('[role="alert"]') ||
|
|
1930
1947
|
wrapper.querySelector('[data-invalid]') ||
|
|
1931
1948
|
wrapper.querySelector('[data-error]');
|
|
@@ -1955,7 +1972,7 @@ async function readFormLevelInvalidState(handle) {
|
|
|
1955
1972
|
return true;
|
|
1956
1973
|
}
|
|
1957
1974
|
return false;
|
|
1958
|
-
});
|
|
1975
|
+
}, requireWrapperFlag);
|
|
1959
1976
|
}
|
|
1960
1977
|
catch {
|
|
1961
1978
|
return false;
|
|
@@ -2532,22 +2549,130 @@ async function attachViaChooser(page, paths, clickX, clickY) {
|
|
|
2532
2549
|
]);
|
|
2533
2550
|
await chooser.setFiles(paths);
|
|
2534
2551
|
}
|
|
2535
|
-
/**
|
|
2552
|
+
/**
|
|
2553
|
+
* Map common file extensions to MIME types so react-dropzone's `accept` filter
|
|
2554
|
+
* does not silently drop our synthetic files. Defaults to application/octet-stream
|
|
2555
|
+
* for unknowns, which is rejected by most dropzones configured with accept.
|
|
2556
|
+
*/
|
|
2557
|
+
function mimeTypeForPath(path) {
|
|
2558
|
+
const ext = path.toLowerCase().split('.').pop() ?? '';
|
|
2559
|
+
switch (ext) {
|
|
2560
|
+
case 'pdf': return 'application/pdf';
|
|
2561
|
+
case 'doc': return 'application/msword';
|
|
2562
|
+
case 'docx': return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
|
|
2563
|
+
case 'txt': return 'text/plain';
|
|
2564
|
+
case 'rtf': return 'application/rtf';
|
|
2565
|
+
case 'png': return 'image/png';
|
|
2566
|
+
case 'jpg':
|
|
2567
|
+
case 'jpeg': return 'image/jpeg';
|
|
2568
|
+
case 'gif': return 'image/gif';
|
|
2569
|
+
case 'webp': return 'image/webp';
|
|
2570
|
+
case 'svg': return 'image/svg+xml';
|
|
2571
|
+
default: return 'application/octet-stream';
|
|
2572
|
+
}
|
|
2573
|
+
}
|
|
2574
|
+
/**
|
|
2575
|
+
* Synthetic drop at (x,y) using file bytes from the proxy host.
|
|
2576
|
+
*
|
|
2577
|
+
* react-dropzone (widely used by Greenhouse, Ashby, and others) does NOT listen
|
|
2578
|
+
* for a bare `drop` event — its `getRootProps` wires up a full
|
|
2579
|
+
* `dragenter` → `dragover` → `drop` sequence, and react-dropzone's internal
|
|
2580
|
+
* state machine silently ignores a `drop` that never saw `dragenter`/`dragover`.
|
|
2581
|
+
* It also requires a proper `dataTransfer.types` containing "Files" AND correct
|
|
2582
|
+
* MIME types for any configured `accept` filter, otherwise the file is
|
|
2583
|
+
* rejected.
|
|
2584
|
+
*
|
|
2585
|
+
* We dispatch the full sequence on the deepest visible element at (x,y) and
|
|
2586
|
+
* bubble up — plus a fallback walk-up that targets any ancestor whose class
|
|
2587
|
+
* names, data-attributes, or role look dropzone-ish, so we hit both the inner
|
|
2588
|
+
* button layer and the wrapper that actually has the listeners.
|
|
2589
|
+
*/
|
|
2536
2590
|
async function attachViaDropPlaywright(page, paths, dropX, dropY) {
|
|
2537
2591
|
const fs = await import('node:fs/promises');
|
|
2538
2592
|
const buffers = await Promise.all(paths.map(p => fs.readFile(p)));
|
|
2539
2593
|
const names = paths.map(p => p.split(/[/\\\\]/).pop() ?? 'file');
|
|
2594
|
+
const mimes = paths.map(mimeTypeForPath);
|
|
2540
2595
|
await page.mouse.move(dropX, dropY);
|
|
2541
|
-
await page.mainFrame().evaluate(({ bufs, ns, x, y }) => {
|
|
2542
|
-
const
|
|
2543
|
-
|
|
2544
|
-
|
|
2545
|
-
|
|
2546
|
-
|
|
2596
|
+
await page.mainFrame().evaluate(({ bufs, ns, ms, x, y }) => {
|
|
2597
|
+
const makeDataTransfer = () => {
|
|
2598
|
+
const dt = new DataTransfer();
|
|
2599
|
+
for (let i = 0; i < bufs.length; i++) {
|
|
2600
|
+
const u8 = new Uint8Array(bufs[i]);
|
|
2601
|
+
const blob = new Blob([u8], { type: ms[i] });
|
|
2602
|
+
dt.items.add(new File([blob], ns[i], { type: ms[i] }));
|
|
2603
|
+
}
|
|
2604
|
+
return dt;
|
|
2605
|
+
};
|
|
2606
|
+
const dispatchSequence = (target) => {
|
|
2607
|
+
// Each event needs its own DataTransfer because some frameworks
|
|
2608
|
+
// consume the items list during dragenter/dragover inspection.
|
|
2609
|
+
for (const type of ['dragenter', 'dragover', 'drop']) {
|
|
2610
|
+
const dt = makeDataTransfer();
|
|
2611
|
+
const ev = new DragEvent(type, {
|
|
2612
|
+
bubbles: true,
|
|
2613
|
+
cancelable: true,
|
|
2614
|
+
composed: true,
|
|
2615
|
+
clientX: x,
|
|
2616
|
+
clientY: y,
|
|
2617
|
+
dataTransfer: dt,
|
|
2618
|
+
});
|
|
2619
|
+
// Some browsers/frameworks freeze the dataTransfer getter; re-define
|
|
2620
|
+
// it defensively so listeners read the files.
|
|
2621
|
+
try {
|
|
2622
|
+
Object.defineProperty(ev, 'dataTransfer', { value: dt, configurable: true });
|
|
2623
|
+
}
|
|
2624
|
+
catch { /* ignore */ }
|
|
2625
|
+
target.dispatchEvent(ev);
|
|
2626
|
+
}
|
|
2627
|
+
};
|
|
2628
|
+
const deepest = document.elementFromPoint(x, y);
|
|
2629
|
+
const targets = [];
|
|
2630
|
+
if (deepest)
|
|
2631
|
+
targets.push(deepest);
|
|
2632
|
+
// Walk up a few ancestors looking for anything that looks like a dropzone
|
|
2633
|
+
// root (react-dropzone emits `data-rfd-*`; Greenhouse uses div.drop-zone;
|
|
2634
|
+
// Ashby uses role="button" wrappers). We dispatch on the first dropzone-ish
|
|
2635
|
+
// ancestor AND the deepest element, because we cannot tell statically
|
|
2636
|
+
// which one has the real listener.
|
|
2637
|
+
let p = deepest?.parentElement ?? null;
|
|
2638
|
+
for (let depth = 0; depth < 12 && p; depth++) {
|
|
2639
|
+
const tag = p.tagName.toLowerCase();
|
|
2640
|
+
const attrs = p.getAttributeNames();
|
|
2641
|
+
const looksLikeDropzone = attrs.some(a => a.startsWith('data-rfd') || a === 'data-dropzone' || a === 'data-testid') ||
|
|
2642
|
+
(p.className && typeof p.className === 'string' && /drop[-_]?zone|file[-_]?upload|attach|upload/i.test(p.className)) ||
|
|
2643
|
+
tag === 'label' ||
|
|
2644
|
+
p.getAttribute('role') === 'button';
|
|
2645
|
+
if (looksLikeDropzone && !targets.includes(p))
|
|
2646
|
+
targets.push(p);
|
|
2647
|
+
p = p.parentElement;
|
|
2547
2648
|
}
|
|
2548
|
-
|
|
2549
|
-
|
|
2550
|
-
|
|
2649
|
+
if (targets.length === 0)
|
|
2650
|
+
targets.push(document.body);
|
|
2651
|
+
for (const t of targets)
|
|
2652
|
+
dispatchSequence(t);
|
|
2653
|
+
// As a final fallback, if there is a hidden input[type=file] scoped to
|
|
2654
|
+
// the nearest form/section, set its `files` via DataTransfer and dispatch
|
|
2655
|
+
// the React-friendly native input value setter so react-hook-form notices.
|
|
2656
|
+
const host = deepest?.closest('form, [role="form"], fieldset, section, div') ?? document.body;
|
|
2657
|
+
const fileInput = host.querySelector('input[type="file"]');
|
|
2658
|
+
if (fileInput) {
|
|
2659
|
+
const dt = makeDataTransfer();
|
|
2660
|
+
try {
|
|
2661
|
+
Object.defineProperty(fileInput, 'files', { value: dt.files, configurable: true });
|
|
2662
|
+
}
|
|
2663
|
+
catch { /* ignore */ }
|
|
2664
|
+
try {
|
|
2665
|
+
// React overrides the input value setter; we need the native prototype
|
|
2666
|
+
// setter so React sees the change.
|
|
2667
|
+
const proto = Object.getPrototypeOf(fileInput);
|
|
2668
|
+
const descriptor = Object.getOwnPropertyDescriptor(proto, 'value');
|
|
2669
|
+
descriptor?.set?.call(fileInput, '');
|
|
2670
|
+
}
|
|
2671
|
+
catch { /* ignore */ }
|
|
2672
|
+
fileInput.dispatchEvent(new Event('input', { bubbles: true }));
|
|
2673
|
+
fileInput.dispatchEvent(new Event('change', { bubbles: true }));
|
|
2674
|
+
}
|
|
2675
|
+
}, { bufs: buffers.map(b => Array.from(b)), ns: names, ms: mimes, x: dropX, y: dropY });
|
|
2551
2676
|
}
|
|
2552
2677
|
/**
|
|
2553
2678
|
* `strategy`:
|
|
@@ -3103,6 +3228,23 @@ async function attemptNativeBatchFill(page, fields) {
|
|
|
3103
3228
|
return explicit;
|
|
3104
3229
|
return el.textContent?.trim() || undefined;
|
|
3105
3230
|
}
|
|
3231
|
+
// Snapshot every state attribute that React-style stateful buttons
|
|
3232
|
+
// typically toggle on selection. Used to verify that a click on a
|
|
3233
|
+
// button-shaped group radio actually committed (not a structural
|
|
3234
|
+
// no-op). Mirrors `selectionSignature` in `chooseValueFromLabeledGroup`
|
|
3235
|
+
// — kept in sync because they live in different page.evaluate scopes.
|
|
3236
|
+
function selectionSignature(opts) {
|
|
3237
|
+
return opts.map(el => {
|
|
3238
|
+
const ariaPressed = el.getAttribute('aria-pressed') ?? '';
|
|
3239
|
+
const ariaChecked = el.getAttribute('aria-checked') ?? '';
|
|
3240
|
+
const ariaSelected = el.getAttribute('aria-selected') ?? '';
|
|
3241
|
+
const dataState = el.getAttribute('data-state') ?? '';
|
|
3242
|
+
const dataSelected = el.getAttribute('data-selected') ?? '';
|
|
3243
|
+
const className = (el instanceof HTMLElement ? el.className : '') ?? '';
|
|
3244
|
+
const checked = el instanceof HTMLInputElement ? String(el.checked) : '';
|
|
3245
|
+
return `${ariaPressed}|${ariaChecked}|${ariaSelected}|${dataState}|${dataSelected}|${className}|${checked}`;
|
|
3246
|
+
}).join('||');
|
|
3247
|
+
}
|
|
3106
3248
|
function setGroupedChoice(fieldLabel, value, exact) {
|
|
3107
3249
|
const groups = Array.from(document.querySelectorAll('fieldset, [role="radiogroup"], [role="group"]'))
|
|
3108
3250
|
.filter((el) => visible(el) && matches(groupPrompt(el), fieldLabel, exact));
|
|
@@ -3129,7 +3271,30 @@ async function attemptNativeBatchFill(page, fields) {
|
|
|
3129
3271
|
return control.checked;
|
|
3130
3272
|
return true;
|
|
3131
3273
|
}
|
|
3274
|
+
// role=radio / role=checkbox: click and verify aria-checked
|
|
3275
|
+
// / aria-selected. The native batch fill used to silently return
|
|
3276
|
+
// true here, which masked Pinecone's commit-on-rerender bug.
|
|
3277
|
+
if (option.getAttribute('role') === 'radio' || option.getAttribute('role') === 'checkbox') {
|
|
3278
|
+
option.click();
|
|
3279
|
+
return (option.getAttribute('aria-checked') === 'true' ||
|
|
3280
|
+
option.getAttribute('aria-selected') === 'true');
|
|
3281
|
+
}
|
|
3282
|
+
// Plain <button> path. This is where Pinecone / LangChain Ashby
|
|
3283
|
+
// Yes/No groups land. The native batch fill previously returned
|
|
3284
|
+
// true unconditionally, masking the silent-fail mode where the
|
|
3285
|
+
// form re-renders mid-flow with a shifted field-id prefix that
|
|
3286
|
+
// wipes the radio's commit state. Snapshot the group's selection
|
|
3287
|
+
// signature, click, then re-snapshot — if nothing changed, the
|
|
3288
|
+
// click was a structural no-op and we return false so the higher-
|
|
3289
|
+
// level setFieldChoice fallback (chooseValueFromLabeledGroup,
|
|
3290
|
+
// which has its own retry logic) can take over. Bug surfaced by
|
|
3291
|
+
// JobForge round-2 marathon — Pinecone Sr SWE Database Team
|
|
3292
|
+
// #320 and LangChain SE Manager #325.
|
|
3293
|
+
const beforeSig = selectionSignature(options);
|
|
3132
3294
|
option.click();
|
|
3295
|
+
const afterSig = selectionSignature(options);
|
|
3296
|
+
if (beforeSig === afterSig)
|
|
3297
|
+
return false;
|
|
3133
3298
|
return true;
|
|
3134
3299
|
}
|
|
3135
3300
|
}
|
|
@@ -3384,9 +3549,25 @@ async function findOtpBoxGroup(page, fieldLabel, expectedLength) {
|
|
|
3384
3549
|
// address them unambiguously via a single selector. Using a
|
|
3385
3550
|
// content-hash-ish random id avoids collisions with existing
|
|
3386
3551
|
// data attributes and re-renders across re-entry.
|
|
3552
|
+
//
|
|
3553
|
+
// Before stamping the new marker, strip any STALE markers from
|
|
3554
|
+
// ALL prior calls anywhere in the document. Without this, a re-rendered
|
|
3555
|
+
// OTP form (e.g. after a stale-OTP submit failure) can leave detached
|
|
3556
|
+
// nodes carrying old data-geometra-otp-* attributes that selectors
|
|
3557
|
+
// could otherwise hit before resolving the live cells. (Bug surfaced
|
|
3558
|
+
// by JobForge round-2 marathon — Glean ML Engineer #174.)
|
|
3559
|
+
const stale = document.querySelectorAll('[data-geometra-otp-stamp]');
|
|
3560
|
+
for (const node of Array.from(stale)) {
|
|
3561
|
+
for (const attr of Array.from(node.attributes)) {
|
|
3562
|
+
if (attr.name.startsWith('data-geometra-otp-')) {
|
|
3563
|
+
node.removeAttribute(attr.name);
|
|
3564
|
+
}
|
|
3565
|
+
}
|
|
3566
|
+
}
|
|
3387
3567
|
const marker = `data-geometra-otp-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
|
3388
3568
|
best.inputs.forEach((input, index) => {
|
|
3389
3569
|
input.setAttribute(marker, String(index));
|
|
3570
|
+
input.setAttribute('data-geometra-otp-stamp', '1');
|
|
3390
3571
|
});
|
|
3391
3572
|
return { cssSelector: `[${marker}]`, count: best.inputs.length };
|
|
3392
3573
|
}, { scopeHint: scope, wantLength: expectedLength });
|
|
@@ -3428,6 +3609,17 @@ export async function fillOtp(page, value, opts) {
|
|
|
3428
3609
|
if (!group) {
|
|
3429
3610
|
throw new Error(`fillOtp: no OTP box group found${opts?.fieldLabel ? ` near label "${opts.fieldLabel}"` : ''}. Expected ≥2 sibling <input maxlength="1"> elements on the same row.`);
|
|
3430
3611
|
}
|
|
3612
|
+
// Sanity check: the locator we just stamped must resolve to exactly the
|
|
3613
|
+
// expected number of cells, all connected to the live document. If the
|
|
3614
|
+
// form re-rendered between findOtpBoxGroup's marker stamp and now, the
|
|
3615
|
+
// marker may have been wiped from the DOM and the count drops to 0.
|
|
3616
|
+
// Throw a clear stale-cell error rather than proceeding to type into
|
|
3617
|
+
// nothing. (Bug surfaced by JobForge round-2 marathon — Glean ML
|
|
3618
|
+
// Engineer #174 second-attempt failure.)
|
|
3619
|
+
const liveCount = await group.boxes.count();
|
|
3620
|
+
if (liveCount !== group.cellCount) {
|
|
3621
|
+
throw new Error(`fillOtp: OTP cell group went stale before fill (expected ${group.cellCount} cells, found ${liveCount}). The form likely re-rendered between detection and fill — retry the call.`);
|
|
3622
|
+
}
|
|
3431
3623
|
// Click the leftmost box via its center point. We use a low-level
|
|
3432
3624
|
// bounding-box click because a semantic "click the first input" path
|
|
3433
3625
|
// resolves to whichever input paints topmost in the stacking order,
|
|
@@ -3441,6 +3633,22 @@ export async function fillOtp(page, value, opts) {
|
|
|
3441
3633
|
throw new Error('fillOtp: first OTP cell has no bounding box (detached or invisible?)');
|
|
3442
3634
|
}
|
|
3443
3635
|
await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2);
|
|
3636
|
+
// Verify focus actually landed on a cell of THIS group (not a stacked
|
|
3637
|
+
// overlay or a sibling element that happened to share the click point).
|
|
3638
|
+
// If the click missed, typing would feed characters to whatever element
|
|
3639
|
+
// happens to hold focus — usually nothing useful — and the readback
|
|
3640
|
+
// would mysteriously show all-empty cells.
|
|
3641
|
+
const focusedOnGroup = await firstBox.evaluate((el) => {
|
|
3642
|
+
const active = document.activeElement;
|
|
3643
|
+
if (!active)
|
|
3644
|
+
return false;
|
|
3645
|
+
// Either the click landed on cell 0 itself, or focus auto-advanced to
|
|
3646
|
+
// a sibling cell with the same data-geometra-otp-stamp.
|
|
3647
|
+
return active === el || (active instanceof HTMLElement && active.hasAttribute('data-geometra-otp-stamp'));
|
|
3648
|
+
});
|
|
3649
|
+
if (!focusedOnGroup) {
|
|
3650
|
+
throw new Error('fillOtp: bounding-box click on cell 0 did not land focus inside the OTP group. The cell may be covered by an overlay or the group is stale — retry after re-detecting.');
|
|
3651
|
+
}
|
|
3444
3652
|
// Clear any pre-existing values in all cells so we write a clean slate.
|
|
3445
3653
|
// Some widgets pre-populate cells with zero-width spaces or previous
|
|
3446
3654
|
// values after a re-render; typing over them would offset the
|
|
@@ -3471,35 +3679,131 @@ export async function fillOtp(page, value, opts) {
|
|
|
3471
3679
|
await page.keyboard.type(value, { delay: perCharDelay });
|
|
3472
3680
|
// Small settle window for the last cell's onChange propagation.
|
|
3473
3681
|
await delay(80);
|
|
3474
|
-
// Verify: read every cell's value and confirm per-char match.
|
|
3475
|
-
const readback = [];
|
|
3476
|
-
for (let i = 0; i < perCellCount; i++) {
|
|
3477
|
-
const cellValue = await group.boxes.nth(i).evaluate((el) => {
|
|
3478
|
-
if (el instanceof HTMLInputElement)
|
|
3479
|
-
return el.value;
|
|
3480
|
-
if (el instanceof HTMLElement)
|
|
3481
|
-
return el.textContent ?? '';
|
|
3482
|
-
return '';
|
|
3483
|
-
});
|
|
3484
|
-
readback.push(cellValue ?? '');
|
|
3485
|
-
}
|
|
3486
|
-
// For each typed char, the corresponding cell must contain exactly that
|
|
3487
|
-
// char. Cells beyond the typed length are allowed to be empty.
|
|
3488
3682
|
const expected = Array.from(value);
|
|
3489
|
-
const
|
|
3490
|
-
|
|
3491
|
-
|
|
3492
|
-
|
|
3683
|
+
const liveGroup = group;
|
|
3684
|
+
// Readback helper — reads every cell's current value / text content so
|
|
3685
|
+
// we can compare against the expected char at the same index.
|
|
3686
|
+
async function readCurrentCells() {
|
|
3687
|
+
const out = [];
|
|
3688
|
+
for (let i = 0; i < perCellCount; i++) {
|
|
3689
|
+
const cellValue = await liveGroup.boxes.nth(i).evaluate((el) => {
|
|
3690
|
+
if (el instanceof HTMLInputElement)
|
|
3691
|
+
return el.value;
|
|
3692
|
+
if (el instanceof HTMLElement)
|
|
3693
|
+
return el.textContent ?? '';
|
|
3694
|
+
return '';
|
|
3695
|
+
});
|
|
3696
|
+
out.push(cellValue ?? '');
|
|
3493
3697
|
}
|
|
3494
|
-
|
|
3698
|
+
return out;
|
|
3699
|
+
}
|
|
3700
|
+
let readback = await readCurrentCells();
|
|
3701
|
+
let mismatches = computeOtpMismatches(readback, expected);
|
|
3702
|
+
// Recovery path: some Greenhouse / React builds let cell 0 accept the
|
|
3703
|
+
// entire string because the onKeyDown auto-advance handler fires after
|
|
3704
|
+
// the input event, so the first char lands in cell 0 but the second
|
|
3705
|
+
// char also lands there (no maxlength enforcement on a controlled input)
|
|
3706
|
+
// — cells 1..N-1 end up empty and cell 0 holds the full value. Also
|
|
3707
|
+
// observed: partial fills where cells 0..K hold multiple chars and
|
|
3708
|
+
// cells K+1.. hold the rest. Rather than throwing and asking the caller
|
|
3709
|
+
// to retry fresh (which often hits the same race), we do a deterministic
|
|
3710
|
+
// per-cell recovery: clear every cell again, then for EACH cell
|
|
3711
|
+
// individually click its center and type exactly one char. This is
|
|
3712
|
+
// slower (~N clicks) but guarantees a 1:1 mapping regardless of whether
|
|
3713
|
+
// the widget's focus-advance handler is working. Surfaced by the
|
|
3714
|
+
// JobForge Hex AI Engineering Lead #310 apply flow on 2026-04-11, where
|
|
3715
|
+
// the initial `page.keyboard.type` dumped "ctUwV3" into cell 0 and
|
|
3716
|
+
// "c"/"t"/"U" into cells 5/6/7 (focus advanced erratically late in the
|
|
3717
|
+
// typing sequence), and the post-verify readback momentarily showed a
|
|
3718
|
+
// stale-but-matching state so the tool returned success.
|
|
3719
|
+
if (mismatches.length > 0) {
|
|
3720
|
+
// Capture per-cell bounding boxes BEFORE any clearing / re-typing so
|
|
3721
|
+
// we have stable screen coordinates to click even if the widget
|
|
3722
|
+
// re-renders mid-recovery.
|
|
3723
|
+
const cellBoxes = [];
|
|
3724
|
+
for (let i = 0; i < perCellCount; i++) {
|
|
3725
|
+
try {
|
|
3726
|
+
cellBoxes.push(await group.boxes.nth(i).boundingBox());
|
|
3727
|
+
}
|
|
3728
|
+
catch {
|
|
3729
|
+
cellBoxes.push(null);
|
|
3730
|
+
}
|
|
3731
|
+
}
|
|
3732
|
+
// Clear every cell again. React-controlled cells sometimes reject a
|
|
3733
|
+
// direct `.value = ''` the first time; pairing the DOM reset with a
|
|
3734
|
+
// keyboard Select-All + Delete cycle per cell during the type loop
|
|
3735
|
+
// below is more reliable.
|
|
3736
|
+
for (let i = 0; i < perCellCount; i++) {
|
|
3737
|
+
try {
|
|
3738
|
+
await group.boxes.nth(i).evaluate((el) => {
|
|
3739
|
+
if (el instanceof HTMLInputElement) {
|
|
3740
|
+
el.value = '';
|
|
3741
|
+
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
3742
|
+
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
3743
|
+
}
|
|
3744
|
+
});
|
|
3745
|
+
}
|
|
3746
|
+
catch {
|
|
3747
|
+
/* tolerate */
|
|
3748
|
+
}
|
|
3749
|
+
}
|
|
3750
|
+
// Per-cell typing: click each cell at its center, then type the single
|
|
3751
|
+
// expected char. We do NOT rely on the widget's auto-advance — we
|
|
3752
|
+
// always explicitly click the next cell for the next char. Before
|
|
3753
|
+
// typing, reset the target cell's value directly in the DOM so the
|
|
3754
|
+
// new char overwrites instead of appending. This is faster and more
|
|
3755
|
+
// deterministic than going through the keyboard Select-All + Delete
|
|
3756
|
+
// path on every cell.
|
|
3757
|
+
for (let i = 0; i < expected.length; i++) {
|
|
3758
|
+
const cellBox = cellBoxes[i];
|
|
3759
|
+
if (!cellBox)
|
|
3760
|
+
continue;
|
|
3761
|
+
try {
|
|
3762
|
+
await liveGroup.boxes.nth(i).evaluate((el) => {
|
|
3763
|
+
if (el instanceof HTMLInputElement) {
|
|
3764
|
+
el.value = '';
|
|
3765
|
+
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
3766
|
+
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
3767
|
+
}
|
|
3768
|
+
});
|
|
3769
|
+
}
|
|
3770
|
+
catch {
|
|
3771
|
+
/* tolerate */
|
|
3772
|
+
}
|
|
3773
|
+
const cx = cellBox.x + cellBox.width / 2;
|
|
3774
|
+
const cy = cellBox.y + cellBox.height / 2;
|
|
3775
|
+
await page.mouse.click(cx, cy);
|
|
3776
|
+
await page.keyboard.type(expected[i], { delay: perCharDelay });
|
|
3777
|
+
}
|
|
3778
|
+
// Settle, re-read, re-compare.
|
|
3779
|
+
await delay(60);
|
|
3780
|
+
readback = await readCurrentCells();
|
|
3781
|
+
mismatches = computeOtpMismatches(readback, expected);
|
|
3782
|
+
}
|
|
3783
|
+
// Final verification. If recovery did not resolve the mismatch, throw
|
|
3784
|
+
// with the full diagnostic so callers get an honest failure instead of
|
|
3785
|
+
// a silent "success" that leaves cells holding the wrong chars.
|
|
3495
3786
|
if (mismatches.length > 0) {
|
|
3787
|
+
const allEmpty = readback.slice(0, expected.length).every((v) => v === '');
|
|
3788
|
+
if (allEmpty) {
|
|
3789
|
+
throw new Error(`fillOtp: typed ${expected.length} chars but ALL ${expected.length} target cells are still empty after per-cell recovery. Focus was lost mid-flow or the cell group was re-rendered after detection. Retry the call to re-detect the live cells.`);
|
|
3790
|
+
}
|
|
3496
3791
|
const summary = mismatches
|
|
3497
3792
|
.map((m) => `cell ${m.index}: expected "${m.expected}", got "${m.got}"`)
|
|
3498
3793
|
.join('; ');
|
|
3499
|
-
throw new Error(`fillOtp: typed ${expected.length} chars into ${perCellCount}-cell group but readback mismatch — ${summary}. Readback: [${readback.map((v) => JSON.stringify(v)).join(', ')}]`);
|
|
3794
|
+
throw new Error(`fillOtp: typed ${expected.length} chars into ${perCellCount}-cell group but readback mismatch even after per-cell recovery — ${summary}. Readback: [${readback.map((v) => JSON.stringify(v)).join(', ')}]`);
|
|
3500
3795
|
}
|
|
3501
3796
|
return { cellCount: perCellCount, filledCount: expected.length };
|
|
3502
3797
|
}
|
|
3798
|
+
function computeOtpMismatches(readback, expected) {
|
|
3799
|
+
const mismatches = [];
|
|
3800
|
+
for (let i = 0; i < expected.length; i++) {
|
|
3801
|
+
if (readback[i] !== expected[i]) {
|
|
3802
|
+
mismatches.push({ index: i, expected: expected[i], got: readback[i] ?? '' });
|
|
3803
|
+
}
|
|
3804
|
+
}
|
|
3805
|
+
return mismatches;
|
|
3806
|
+
}
|
|
3503
3807
|
export async function setFieldText(page, fieldLabel, value, opts) {
|
|
3504
3808
|
// Bug #2 (v1.43) auto-routing: if the caller's label looks like a
|
|
3505
3809
|
// verification-code / security-code / OTP prompt, try the dedicated OTP
|
|
@@ -4184,7 +4488,16 @@ export async function pickListboxOption(page, label, opts) {
|
|
|
4184
4488
|
// [data-invalid] flag inside the field wrapper (react-hook-form,
|
|
4185
4489
|
// Formik, Ashby forms, etc.). If the form disagrees with the trigger,
|
|
4186
4490
|
// the commit did not land and the caller should retry.
|
|
4187
|
-
|
|
4491
|
+
//
|
|
4492
|
+
// We use the STRICT variant here (`requireWrapperFlag: false`) because
|
|
4493
|
+
// we just attempted a commit — a required hidden input that is still
|
|
4494
|
+
// empty IS authoritative even before the user submits the form. Without
|
|
4495
|
+
// this, Greenhouse's phone-country-code combobox (Airtable PM AI #94)
|
|
4496
|
+
// false-passes verification: the trigger shows "+1" but the hidden
|
|
4497
|
+
// input stays blank because react-select's keyboard-Enter commit
|
|
4498
|
+
// didn't bind to the country sub-control. Bug surfaced by JobForge
|
|
4499
|
+
// round-2 marathon.
|
|
4500
|
+
if (await readFormLevelInvalidState(openedHandle, { requireWrapperFlag: false }))
|
|
4188
4501
|
return false;
|
|
4189
4502
|
return true;
|
|
4190
4503
|
};
|