@trusty-squire/mcp 1.1.4 → 1.1.5
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 +28 -16
- package/dist/api-client.d.ts +2 -2
- package/dist/api-client.d.ts.map +1 -1
- package/dist/api-client.js.map +1 -1
- package/dist/bot/browser.d.ts +39 -3
- package/dist/bot/browser.d.ts.map +1 -1
- package/dist/bot/browser.js +682 -205
- package/dist/bot/browser.js.map +1 -1
- package/dist/bot/pay-operator.d.ts +2 -1
- package/dist/bot/pay-operator.d.ts.map +1 -1
- package/dist/bot/pay-operator.js +123 -15
- package/dist/bot/pay-operator.js.map +1 -1
- package/dist/bot/provision-session.d.ts +58 -14
- package/dist/bot/provision-session.d.ts.map +1 -1
- package/dist/bot/provision-session.js +757 -64
- package/dist/bot/provision-session.js.map +1 -1
- package/dist/tools/operate-pay.d.ts.map +1 -1
- package/dist/tools/operate-pay.js +36 -11
- package/dist/tools/operate-pay.js.map +1 -1
- package/dist/tools/provision-drive.d.ts +6 -3
- package/dist/tools/provision-drive.d.ts.map +1 -1
- package/dist/tools/provision-drive.js +70 -14
- package/dist/tools/provision-drive.js.map +1 -1
- package/package.json +2 -1
package/dist/bot/browser.js
CHANGED
|
@@ -2251,6 +2251,324 @@ export class BrowserController {
|
|
|
2251
2251
|
const safeIndex = Math.max(0, Math.floor(index));
|
|
2252
2252
|
await this.page.locator(selector).nth(safeIndex).click({ force: true, timeout: 8000 });
|
|
2253
2253
|
}
|
|
2254
|
+
// Resolve a locator-form operate_act target (`text=…` / `css=…`) DIRECTLY
|
|
2255
|
+
// against the live page, bypassing the extracted-inventory list. This is the
|
|
2256
|
+
// escape hatch for a control the inventory never emitted: a bare click-handler
|
|
2257
|
+
// <div> with no role/label/testid that the SELECTOR walk skips and that the
|
|
2258
|
+
// card scan drops once its MAX_CARDS budget is spent on earlier cursor:pointer
|
|
2259
|
+
// divs (Casetify's Add-To-Cart is element #45 of the eligible cards; the cap is
|
|
2260
|
+
// 16). Because there is no ref for such an element, `text=`/`css=` is the only
|
|
2261
|
+
// way the host can click it.
|
|
2262
|
+
//
|
|
2263
|
+
// Resolution rules (kept deliberately strict so the click can't land on the
|
|
2264
|
+
// wrong element):
|
|
2265
|
+
// • text mode — matches an element whose rendered text (innerText, so hidden
|
|
2266
|
+
// descendants don't leak) equals (or, if nothing equals, contains) the
|
|
2267
|
+
// query AND that carries a real click affordance (button/a/label/select
|
|
2268
|
+
// tag, an interactive ARIA role, an onclick / action-type attribute, or
|
|
2269
|
+
// cursor:pointer). Plain prose that merely contains the words is excluded.
|
|
2270
|
+
// Open shadow roots are pierced.
|
|
2271
|
+
// • css mode — the author's selector, restricted to VISIBLE matches.
|
|
2272
|
+
// • A weak (cursor-only) descendant inside a strong control collapses away;
|
|
2273
|
+
// weak ancestors and two GENUINE nested controls stay ambiguous rather
|
|
2274
|
+
// than being silently merged.
|
|
2275
|
+
// • 0 matches → {ok:false, reason:"none"}; >1 → {ok:false, reason:"ambiguous"}
|
|
2276
|
+
// with the candidate texts so the host can disambiguate. Exactly 1 returns
|
|
2277
|
+
// a live ElementHandle to the winner. The caller acts through the handle
|
|
2278
|
+
// (never a DOM-visible marker), so a page MutationObserver cannot re-aim
|
|
2279
|
+
// the click at a decoy between resolution and click, and disposes it after.
|
|
2280
|
+
async resolvePageTarget(mode, value) {
|
|
2281
|
+
if (!this.page)
|
|
2282
|
+
throw new Error("Browser not started");
|
|
2283
|
+
const resultHandle = await this.page.evaluateHandle(({ mode, value }) => {
|
|
2284
|
+
const norm = (s) => (s ?? "").replace(/\s+/g, " ").trim().toLowerCase();
|
|
2285
|
+
// Rendered text, NOT textContent: innerText reflects what the user
|
|
2286
|
+
// actually sees, excluding display:none / visibility:hidden descendants.
|
|
2287
|
+
// Matching on textContent let a visible "Cancel" button that hides a
|
|
2288
|
+
// "Delete account" span be selected by text="Delete account" (codex).
|
|
2289
|
+
// Display form: whitespace-collapsed but ORIGINAL case (for the trace /
|
|
2290
|
+
// audit / candidate list). `rendered` lowercases it for matching only.
|
|
2291
|
+
const renderedRaw = (el) => {
|
|
2292
|
+
const it = el.innerText;
|
|
2293
|
+
return (typeof it === "string" ? it : (el.textContent ?? "")).replace(/\s+/g, " ").trim();
|
|
2294
|
+
};
|
|
2295
|
+
const rendered = (el) => renderedRaw(el).toLowerCase();
|
|
2296
|
+
const safetyMetadata = (value) => (value ?? "")
|
|
2297
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
2298
|
+
.replace(/[_.\/-]+/g, " ")
|
|
2299
|
+
.replace(/\s+/g, " ")
|
|
2300
|
+
.trim()
|
|
2301
|
+
.toLowerCase();
|
|
2302
|
+
const safetySignalsFor = (el) => {
|
|
2303
|
+
const safetyText = [
|
|
2304
|
+
renderedRaw(el),
|
|
2305
|
+
el.getAttribute("aria-label"),
|
|
2306
|
+
el.getAttribute("title"),
|
|
2307
|
+
el.getAttribute("alt"),
|
|
2308
|
+
el.getAttribute("action-type"),
|
|
2309
|
+
el.getAttribute("name"),
|
|
2310
|
+
el.getAttribute("id"),
|
|
2311
|
+
el.getAttribute("value"),
|
|
2312
|
+
]
|
|
2313
|
+
.map((part) => safetyMetadata(part))
|
|
2314
|
+
.filter((part) => part.length > 0)
|
|
2315
|
+
.join(" ");
|
|
2316
|
+
// Keep these predicates in sync with isBillingObjectActionTarget and
|
|
2317
|
+
// isAccountSetupActionTarget in provision-session.ts.
|
|
2318
|
+
return {
|
|
2319
|
+
billingObject: /\b(create|save|add|finish)\b/i.test(safetyText) &&
|
|
2320
|
+
/\b(product|price|pricing|subscription|billing|payment|invoice|checkout)\b/i.test(safetyText),
|
|
2321
|
+
accountSetup: /\b(?:create|finish|complete|set up|setup)\s+(?:your\s+)?(?:account|profile|organization|workspace|business)\b/i.test(safetyText),
|
|
2322
|
+
};
|
|
2323
|
+
};
|
|
2324
|
+
// Visibility walks the ANCESTOR chain (crossing shadow-host boundaries):
|
|
2325
|
+
// opacity does not inherit, so a button under an opacity:0 wrapper keeps
|
|
2326
|
+
// its own computed opacity 1 and a self-only check would wrongly treat it
|
|
2327
|
+
// as visible and click an invisible control (codex).
|
|
2328
|
+
const isVisible = (el) => {
|
|
2329
|
+
const r = el.getBoundingClientRect();
|
|
2330
|
+
if (r.width < 2 || r.height < 2)
|
|
2331
|
+
return false;
|
|
2332
|
+
let node = el;
|
|
2333
|
+
while (node !== null) {
|
|
2334
|
+
const s = window.getComputedStyle(node);
|
|
2335
|
+
if (s.display === "none" || s.visibility === "hidden" || s.opacity === "0")
|
|
2336
|
+
return false;
|
|
2337
|
+
const parentEl = node.parentElement;
|
|
2338
|
+
if (parentEl !== null) {
|
|
2339
|
+
node = parentEl;
|
|
2340
|
+
}
|
|
2341
|
+
else {
|
|
2342
|
+
const root = node.getRootNode();
|
|
2343
|
+
node = root instanceof ShadowRoot ? root.host : null;
|
|
2344
|
+
}
|
|
2345
|
+
}
|
|
2346
|
+
return true;
|
|
2347
|
+
};
|
|
2348
|
+
// "Strong" = real interactive semantics (a genuine control), as opposed
|
|
2349
|
+
// to an element that merely inherits cursor:pointer from a clickable
|
|
2350
|
+
// ancestor (a decorative wrapper / inner label span).
|
|
2351
|
+
const isStrong = (el) => {
|
|
2352
|
+
const tag = el.tagName.toLowerCase();
|
|
2353
|
+
const role = el.getAttribute("role");
|
|
2354
|
+
if (tag === "button" ||
|
|
2355
|
+
tag === "a" ||
|
|
2356
|
+
tag === "label" ||
|
|
2357
|
+
tag === "select" ||
|
|
2358
|
+
tag === "summary")
|
|
2359
|
+
return true;
|
|
2360
|
+
if (role === "button" ||
|
|
2361
|
+
role === "link" ||
|
|
2362
|
+
role === "radio" ||
|
|
2363
|
+
role === "checkbox" ||
|
|
2364
|
+
role === "menuitem" ||
|
|
2365
|
+
role === "menuitemradio" ||
|
|
2366
|
+
role === "option" ||
|
|
2367
|
+
role === "tab" ||
|
|
2368
|
+
role === "switch")
|
|
2369
|
+
return true;
|
|
2370
|
+
return el.hasAttribute("onclick") || el.hasAttribute("action-type");
|
|
2371
|
+
};
|
|
2372
|
+
const hasClickAffordance = (el) => isStrong(el) || window.getComputedStyle(el).cursor === "pointer";
|
|
2373
|
+
// Gather candidates across the light DOM and every OPEN shadow root.
|
|
2374
|
+
const all = [];
|
|
2375
|
+
const collect = (root) => {
|
|
2376
|
+
if (root == null || typeof root.querySelectorAll !== "function")
|
|
2377
|
+
return;
|
|
2378
|
+
let nodes = [];
|
|
2379
|
+
if (mode === "css") {
|
|
2380
|
+
try {
|
|
2381
|
+
nodes = Array.from(root.querySelectorAll(value));
|
|
2382
|
+
}
|
|
2383
|
+
catch {
|
|
2384
|
+
nodes = [];
|
|
2385
|
+
}
|
|
2386
|
+
}
|
|
2387
|
+
else {
|
|
2388
|
+
nodes = Array.from(root.querySelectorAll("*"));
|
|
2389
|
+
}
|
|
2390
|
+
for (const n of nodes)
|
|
2391
|
+
all.push(n);
|
|
2392
|
+
for (const el of Array.from(root.querySelectorAll("*"))) {
|
|
2393
|
+
const sr = el.shadowRoot;
|
|
2394
|
+
if (sr != null)
|
|
2395
|
+
collect(sr);
|
|
2396
|
+
}
|
|
2397
|
+
};
|
|
2398
|
+
collect(document);
|
|
2399
|
+
let pool;
|
|
2400
|
+
if (mode === "css") {
|
|
2401
|
+
pool = all.filter(isVisible);
|
|
2402
|
+
}
|
|
2403
|
+
else {
|
|
2404
|
+
const want = norm(value);
|
|
2405
|
+
if (want.length === 0) {
|
|
2406
|
+
return {
|
|
2407
|
+
element: null,
|
|
2408
|
+
count: 0,
|
|
2409
|
+
candidates: [],
|
|
2410
|
+
text: "",
|
|
2411
|
+
safetySignals: { billingObject: false, accountSetup: false },
|
|
2412
|
+
};
|
|
2413
|
+
}
|
|
2414
|
+
const affordable = all.filter((el) => isVisible(el) && hasClickAffordance(el));
|
|
2415
|
+
const exact = affordable.filter((el) => rendered(el) === want);
|
|
2416
|
+
// Prefer exact-text matches; only fall back to "contains" (with a
|
|
2417
|
+
// length guard so a big wrapper doesn't swallow the query) when no
|
|
2418
|
+
// element's rendered text equals the query.
|
|
2419
|
+
pool =
|
|
2420
|
+
exact.length > 0
|
|
2421
|
+
? exact
|
|
2422
|
+
: affordable.filter((el) => {
|
|
2423
|
+
const t = rendered(el);
|
|
2424
|
+
return t.includes(want) && t.length <= Math.max(80, want.length + 20);
|
|
2425
|
+
});
|
|
2426
|
+
}
|
|
2427
|
+
// Bound the O(n²) nesting-collapse below: a broad selector (css=* /
|
|
2428
|
+
// css=div) can match thousands of nodes, and a pairwise `contains` scan
|
|
2429
|
+
// over all of them would block the page for seconds. A pool this large
|
|
2430
|
+
// is ambiguous by any measure (the caller wants exactly one), so
|
|
2431
|
+
// short-circuit to ambiguous before the quadratic pass (no-mistakes review).
|
|
2432
|
+
const AMBIGUOUS_POOL_CAP = 40;
|
|
2433
|
+
if (pool.length > AMBIGUOUS_POOL_CAP) {
|
|
2434
|
+
return {
|
|
2435
|
+
element: null,
|
|
2436
|
+
count: pool.length,
|
|
2437
|
+
candidates: pool.slice(0, 8).map((el) => renderedRaw(el).slice(0, 60)),
|
|
2438
|
+
text: "",
|
|
2439
|
+
safetySignals: { billingObject: false, accountSetup: false },
|
|
2440
|
+
};
|
|
2441
|
+
}
|
|
2442
|
+
// Collapse nesting WITHOUT silently merging two genuine controls. A
|
|
2443
|
+
// STRONG candidate (real interactive semantics) always survives. A WEAK
|
|
2444
|
+
// candidate (only inherits cursor:pointer — a decorative wrapper or the
|
|
2445
|
+
// inner label span of a real control) is dropped only when it sits
|
|
2446
|
+
// inside a strong candidate (it's part of that control's subtree, e.g.
|
|
2447
|
+
// Casetify's <span> inside the button <div>). Two WEAK candidates in a
|
|
2448
|
+
// nesting relationship — each a bare click-handler div with its own
|
|
2449
|
+
// listener — are NOT collapsed:
|
|
2450
|
+
// dropping the outer would pick the inner, whose click bubbles to the
|
|
2451
|
+
// outer and fires BOTH handlers (a double add-to-cart). They both
|
|
2452
|
+
// survive → reported ambiguous rather than silently double-clicked (codex).
|
|
2453
|
+
const leaves = pool.filter((el) => {
|
|
2454
|
+
if (isStrong(el))
|
|
2455
|
+
return true;
|
|
2456
|
+
for (const other of pool) {
|
|
2457
|
+
if (other === el)
|
|
2458
|
+
continue;
|
|
2459
|
+
if (other.contains(el) && isStrong(other))
|
|
2460
|
+
return false;
|
|
2461
|
+
}
|
|
2462
|
+
return true;
|
|
2463
|
+
});
|
|
2464
|
+
const uniq = Array.from(new Set(leaves));
|
|
2465
|
+
const candidates = uniq.slice(0, 8).map((el) => renderedRaw(el).slice(0, 60));
|
|
2466
|
+
const win = uniq.length === 1 ? uniq[0] : null;
|
|
2467
|
+
return {
|
|
2468
|
+
element: win,
|
|
2469
|
+
count: uniq.length,
|
|
2470
|
+
candidates,
|
|
2471
|
+
text: win !== null ? renderedRaw(win).slice(0, 120) : "",
|
|
2472
|
+
safetySignals: win !== null ? safetySignalsFor(win) : { billingObject: false, accountSetup: false },
|
|
2473
|
+
};
|
|
2474
|
+
}, { mode, value });
|
|
2475
|
+
const meta = await resultHandle.evaluate((r) => ({
|
|
2476
|
+
count: r.count,
|
|
2477
|
+
candidates: r.candidates,
|
|
2478
|
+
text: r.text,
|
|
2479
|
+
safetySignals: r.safetySignals,
|
|
2480
|
+
}));
|
|
2481
|
+
if (meta.count !== 1) {
|
|
2482
|
+
await resultHandle.dispose();
|
|
2483
|
+
return {
|
|
2484
|
+
ok: false,
|
|
2485
|
+
reason: meta.count === 0 ? "none" : "ambiguous",
|
|
2486
|
+
candidates: meta.candidates,
|
|
2487
|
+
};
|
|
2488
|
+
}
|
|
2489
|
+
// Pull out a live ElementHandle to the winning node; dispose the wrapper.
|
|
2490
|
+
const winHandle = await resultHandle.evaluateHandle((r) => r.element);
|
|
2491
|
+
await resultHandle.dispose();
|
|
2492
|
+
const asElement = winHandle.asElement();
|
|
2493
|
+
if (asElement === null) {
|
|
2494
|
+
await winHandle.dispose();
|
|
2495
|
+
return { ok: false, reason: "none", candidates: meta.candidates };
|
|
2496
|
+
}
|
|
2497
|
+
return {
|
|
2498
|
+
ok: true,
|
|
2499
|
+
handle: asElement,
|
|
2500
|
+
text: meta.text ?? "",
|
|
2501
|
+
safetySignals: meta.safetySignals ?? { billingObject: false, accountSetup: false },
|
|
2502
|
+
};
|
|
2503
|
+
}
|
|
2504
|
+
async locatorClickState(handle) {
|
|
2505
|
+
return await handle.evaluate((el) => {
|
|
2506
|
+
if (!el.isConnected)
|
|
2507
|
+
return "detached";
|
|
2508
|
+
if (typeof el.matches === "function" && el.matches(":disabled"))
|
|
2509
|
+
return "disabled";
|
|
2510
|
+
let n = el;
|
|
2511
|
+
while (n !== null) {
|
|
2512
|
+
if (n.getAttribute("aria-disabled") === "true")
|
|
2513
|
+
return "disabled";
|
|
2514
|
+
const parentEl = n.parentElement;
|
|
2515
|
+
if (parentEl !== null) {
|
|
2516
|
+
n = parentEl;
|
|
2517
|
+
}
|
|
2518
|
+
else {
|
|
2519
|
+
const root = n.getRootNode();
|
|
2520
|
+
n = root instanceof ShadowRoot ? root.host : null;
|
|
2521
|
+
}
|
|
2522
|
+
}
|
|
2523
|
+
return "ok";
|
|
2524
|
+
});
|
|
2525
|
+
}
|
|
2526
|
+
async clickHandle(handle) {
|
|
2527
|
+
const state = await this.locatorClickState(handle);
|
|
2528
|
+
if (state === "detached") {
|
|
2529
|
+
throw new Error("locator target detached from the page before the click");
|
|
2530
|
+
}
|
|
2531
|
+
if (state === "disabled") {
|
|
2532
|
+
throw new Error("locator target is disabled");
|
|
2533
|
+
}
|
|
2534
|
+
await handle.click({ timeout: 8000, noWaitAfter: true });
|
|
2535
|
+
}
|
|
2536
|
+
async jsClickHandle(handle) {
|
|
2537
|
+
const state = await this.locatorClickState(handle);
|
|
2538
|
+
if (state === "detached") {
|
|
2539
|
+
throw new Error("locator target detached from the page before the click");
|
|
2540
|
+
}
|
|
2541
|
+
if (state === "disabled") {
|
|
2542
|
+
throw new Error("locator target is disabled");
|
|
2543
|
+
}
|
|
2544
|
+
const dispatchState = await handle.evaluate((el) => {
|
|
2545
|
+
if (!el.isConnected)
|
|
2546
|
+
return "detached";
|
|
2547
|
+
if (typeof el.matches === "function" && el.matches(":disabled"))
|
|
2548
|
+
return "disabled";
|
|
2549
|
+
let n = el;
|
|
2550
|
+
while (n !== null) {
|
|
2551
|
+
if (n.getAttribute("aria-disabled") === "true")
|
|
2552
|
+
return "disabled";
|
|
2553
|
+
const parentEl = n.parentElement;
|
|
2554
|
+
if (parentEl !== null) {
|
|
2555
|
+
n = parentEl;
|
|
2556
|
+
}
|
|
2557
|
+
else {
|
|
2558
|
+
const root = n.getRootNode();
|
|
2559
|
+
n = root instanceof ShadowRoot ? root.host : null;
|
|
2560
|
+
}
|
|
2561
|
+
}
|
|
2562
|
+
el.click();
|
|
2563
|
+
return "ok";
|
|
2564
|
+
});
|
|
2565
|
+
if (dispatchState === "detached") {
|
|
2566
|
+
throw new Error("locator target detached from the page before the click");
|
|
2567
|
+
}
|
|
2568
|
+
if (dispatchState === "disabled") {
|
|
2569
|
+
throw new Error("locator target is disabled");
|
|
2570
|
+
}
|
|
2571
|
+
}
|
|
2254
2572
|
// Dispatch a DOM .click() in the page context. Some React copy buttons fire
|
|
2255
2573
|
// their onClick (and thus navigator.clipboard.writeText) on the synthetic
|
|
2256
2574
|
// event a real Playwright mouse click doesn't reliably reproduce (deepinfra's
|
|
@@ -3015,14 +3333,19 @@ export class BrowserController {
|
|
|
3015
3333
|
.find((o) => o.textContent?.toLowerCase().includes(needle));
|
|
3016
3334
|
return hit !== undefined ? { value: hit.value } : null;
|
|
3017
3335
|
}, matcherLower);
|
|
3018
|
-
if (matched
|
|
3019
|
-
|
|
3336
|
+
if (matched === null) {
|
|
3337
|
+
throw new Error(`<select> ${activeSelector}: no option matched ${JSON.stringify(optionMatcher)}`);
|
|
3020
3338
|
}
|
|
3339
|
+
chosenValue = matched.value;
|
|
3021
3340
|
}
|
|
3022
3341
|
if (chosenValue === undefined) {
|
|
3023
3342
|
throw new Error(`<select> ${activeSelector} has no selectable option`);
|
|
3024
3343
|
}
|
|
3025
3344
|
await this.page.selectOption(activeSelector, chosenValue);
|
|
3345
|
+
const committedValue = await this.page.locator(activeSelector).first().inputValue();
|
|
3346
|
+
if (committedValue !== chosenValue) {
|
|
3347
|
+
throw new Error(`<select> ${activeSelector}: selected value ${JSON.stringify(chosenValue)} did not stick`);
|
|
3348
|
+
}
|
|
3026
3349
|
// rc.17 — mark the element as touched so subsequent inventory
|
|
3027
3350
|
// reads can suppress the DEFAULTED-dropdown warning for it.
|
|
3028
3351
|
// Without this, a select whose committed value is "" (Railway's
|
|
@@ -3043,40 +3366,252 @@ export class BrowserController {
|
|
|
3043
3366
|
// — every modern React picker emits role=option on its items.
|
|
3044
3367
|
await this.selectFromCombobox(activeSelector, optionMatcher);
|
|
3045
3368
|
}
|
|
3046
|
-
//
|
|
3047
|
-
//
|
|
3048
|
-
//
|
|
3049
|
-
//
|
|
3050
|
-
//
|
|
3051
|
-
//
|
|
3052
|
-
|
|
3053
|
-
|
|
3054
|
-
|
|
3055
|
-
|
|
3056
|
-
|
|
3057
|
-
|
|
3058
|
-
|
|
3059
|
-
|
|
3060
|
-
|
|
3061
|
-
|
|
3062
|
-
|
|
3063
|
-
|
|
3064
|
-
|
|
3065
|
-
//
|
|
3066
|
-
//
|
|
3067
|
-
//
|
|
3068
|
-
//
|
|
3069
|
-
//
|
|
3070
|
-
//
|
|
3071
|
-
//
|
|
3072
|
-
//
|
|
3073
|
-
//
|
|
3074
|
-
//
|
|
3075
|
-
|
|
3076
|
-
|
|
3077
|
-
|
|
3078
|
-
|
|
3079
|
-
|
|
3369
|
+
// Set the country on a phone-number field backed by a phone-local native
|
|
3370
|
+
// <select>, including react-phone-number-input's opacity:0 country select.
|
|
3371
|
+
// The inventory walker omits that hidden control and Playwright refuses to
|
|
3372
|
+
// select it, so this path uses the native value setter, dispatches change,
|
|
3373
|
+
// and verifies the selected value. Custom phone widget families are not
|
|
3374
|
+
// supported and fail loudly.
|
|
3375
|
+
async setPhoneCountry(country) {
|
|
3376
|
+
if (!this.page)
|
|
3377
|
+
throw new Error("Browser not started");
|
|
3378
|
+
const query = classifyPhoneCountryQuery(country);
|
|
3379
|
+
if (query.dialCode === undefined && query.iso2 === undefined && query.name === undefined) {
|
|
3380
|
+
throw new Error("setPhoneCountry: empty country argument");
|
|
3381
|
+
}
|
|
3382
|
+
await this.clearPhoneCountryMarkers();
|
|
3383
|
+
if (await this.trySetPhoneCountryNativeSelect(query))
|
|
3384
|
+
return;
|
|
3385
|
+
throw new Error("set_phone_country: no supported native phone-country <select> found " +
|
|
3386
|
+
"(this widget family is not supported yet) — enter a valid contact number instead.");
|
|
3387
|
+
}
|
|
3388
|
+
// Strategy 1 — a native <select> that governs the phone country (react-
|
|
3389
|
+
// phone-number-input's `opacity:0` PhoneInputCountrySelect, or any bespoke
|
|
3390
|
+
// widget backed by a real <select>). Detection is deliberately conservative
|
|
3391
|
+
// so it does NOT grab the address "country" select that lives elsewhere on
|
|
3392
|
+
// the checkout: a select qualifies only when its class/name/id names it a
|
|
3393
|
+
// PHONE control and its options carry country evidence, or its options look
|
|
3394
|
+
// like countries AND it is a direct sibling of the tel input or lives in an
|
|
3395
|
+
// immediately adjacent wrapper.
|
|
3396
|
+
// Returns false when no such select exists; throws when one is found but the
|
|
3397
|
+
// requested country isn't among its options.
|
|
3398
|
+
async trySetPhoneCountryNativeSelect(query) {
|
|
3399
|
+
if (!this.page)
|
|
3400
|
+
throw new Error("Browser not started");
|
|
3401
|
+
const candidates = await this.page.evaluate(() => {
|
|
3402
|
+
const out = [];
|
|
3403
|
+
const selects = Array.from(document.querySelectorAll("select"));
|
|
3404
|
+
selects.forEach((sel, i) => {
|
|
3405
|
+
if (!(sel instanceof HTMLSelectElement))
|
|
3406
|
+
return;
|
|
3407
|
+
const hay = `${sel.className} ${sel.getAttribute("name") ?? ""} ${sel.id}`.toLowerCase();
|
|
3408
|
+
const phoneNamed = /phone|dial|calling/.test(hay);
|
|
3409
|
+
let telDistance = Number.POSITIVE_INFINITY;
|
|
3410
|
+
const isTel = (el) => el?.matches('input[type="tel"]') === true;
|
|
3411
|
+
const parent = sel.parentElement;
|
|
3412
|
+
if (parent !== null &&
|
|
3413
|
+
parent.tagName !== "FORM" &&
|
|
3414
|
+
(isTel(sel.previousElementSibling) || isTel(sel.nextElementSibling))) {
|
|
3415
|
+
telDistance = 0;
|
|
3416
|
+
}
|
|
3417
|
+
else if (parent !== null &&
|
|
3418
|
+
parent.tagName !== "FORM" &&
|
|
3419
|
+
Array.from(parent.children).some(isTel)) {
|
|
3420
|
+
telDistance = 1;
|
|
3421
|
+
}
|
|
3422
|
+
const options = Array.from(sel.options).map((o) => ({
|
|
3423
|
+
value: o.value,
|
|
3424
|
+
text: (o.textContent ?? "").replace(/\s+/g, " ").trim(),
|
|
3425
|
+
}));
|
|
3426
|
+
const isoish = options.filter((o) => /^[A-Za-z]{2}$/.test(o.value)).length;
|
|
3427
|
+
const dialish = options.filter((o) => /\+\d/.test(o.text) || /^\+?\d{1,4}$/.test(o.value)).length;
|
|
3428
|
+
const explicitDialish = options.filter((o) => /\+\d/.test(o.text) || /^\+\d{1,4}$/.test(o.value)).length;
|
|
3429
|
+
const countryish = options.length >= 10 && (isoish >= 5 || dialish >= 5);
|
|
3430
|
+
const countryNamed = /country|nation|iso/.test(hay);
|
|
3431
|
+
const dialCodeNamed = /dial|calling/.test(hay);
|
|
3432
|
+
const phoneCountryish = countryish ||
|
|
3433
|
+
explicitDialish > 0 ||
|
|
3434
|
+
(dialCodeNamed && dialish >= 2) ||
|
|
3435
|
+
(countryNamed && isoish >= 2);
|
|
3436
|
+
if ((phoneNamed && phoneCountryish) || (countryish && telDistance <= 1)) {
|
|
3437
|
+
sel.setAttribute("data-ts-phone-cc", String(i));
|
|
3438
|
+
out.push({
|
|
3439
|
+
marker: i,
|
|
3440
|
+
options,
|
|
3441
|
+
phoneNamed,
|
|
3442
|
+
telDistance: telDistance === Number.POSITIVE_INFINITY ? 99 : telDistance,
|
|
3443
|
+
});
|
|
3444
|
+
}
|
|
3445
|
+
});
|
|
3446
|
+
return out;
|
|
3447
|
+
});
|
|
3448
|
+
if (candidates.length === 0)
|
|
3449
|
+
return false;
|
|
3450
|
+
// Prefer a phone-NAMED select, then the one physically closest to a tel
|
|
3451
|
+
// input — the strongest evidence it's the dial-code control, not address.
|
|
3452
|
+
candidates.sort((a, b) => Number(b.phoneNamed) - Number(a.phoneNamed) || a.telDistance - b.telDistance);
|
|
3453
|
+
const best = candidates[0];
|
|
3454
|
+
if (best === undefined)
|
|
3455
|
+
return false;
|
|
3456
|
+
const opts = best.options.map((o) => ({
|
|
3457
|
+
text: o.text.length > 0 ? o.text : undefined,
|
|
3458
|
+
iso2: /^[A-Za-z]{2}$/.test(o.value) ? o.value.toUpperCase() : undefined,
|
|
3459
|
+
dialCode: /^\+?\d{1,4}$/.test(o.value) ? o.value.replace(/\D/g, "") : undefined,
|
|
3460
|
+
}));
|
|
3461
|
+
const idx = pickPhoneCountryOption(query, opts);
|
|
3462
|
+
const chosenOpt = idx === -1 ? undefined : best.options[idx];
|
|
3463
|
+
if (chosenOpt === undefined) {
|
|
3464
|
+
await this.clearPhoneCountryMarkers();
|
|
3465
|
+
const sample = best.options
|
|
3466
|
+
.map((o) => o.text)
|
|
3467
|
+
.filter((t) => t.length > 0)
|
|
3468
|
+
.slice(0, 6)
|
|
3469
|
+
.join(" | ");
|
|
3470
|
+
throw new Error(`setPhoneCountry: native phone <select> found but no option matched ` +
|
|
3471
|
+
`${JSON.stringify(query)} (sample: ${sample})`);
|
|
3472
|
+
}
|
|
3473
|
+
const value = chosenOpt.value;
|
|
3474
|
+
// Set through the native value setter + a dispatched `change` so a React-
|
|
3475
|
+
// controlled select (react-phone-number-input) sees the update: assigning
|
|
3476
|
+
// .value directly is swallowed by React's value tracker, so we go through
|
|
3477
|
+
// the prototype setter the tracker also patches, then fire the event React
|
|
3478
|
+
// listens on. Works on the opacity:0 select without a visibility check.
|
|
3479
|
+
const assigned = await this.page.evaluate(({ marker, val }) => {
|
|
3480
|
+
const sel = document.querySelector(`select[data-ts-phone-cc="${marker}"]`);
|
|
3481
|
+
if (!(sel instanceof HTMLSelectElement))
|
|
3482
|
+
return false;
|
|
3483
|
+
const desc = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, "value");
|
|
3484
|
+
if (desc?.set !== undefined)
|
|
3485
|
+
desc.set.call(sel, val);
|
|
3486
|
+
else
|
|
3487
|
+
sel.value = val;
|
|
3488
|
+
sel.dispatchEvent(new Event("input", { bubbles: true }));
|
|
3489
|
+
sel.dispatchEvent(new Event("change", { bubbles: true }));
|
|
3490
|
+
return true;
|
|
3491
|
+
}, { marker: best.marker, val: value });
|
|
3492
|
+
const committedValue = assigned
|
|
3493
|
+
? await this.page
|
|
3494
|
+
.locator(`select[data-ts-phone-cc="${best.marker}"]`)
|
|
3495
|
+
.inputValue()
|
|
3496
|
+
.catch(() => "")
|
|
3497
|
+
: "";
|
|
3498
|
+
await this.clearPhoneCountryMarkers();
|
|
3499
|
+
if (!assigned || committedValue !== value) {
|
|
3500
|
+
throw new Error(`setPhoneCountry: native phone <select> did not retain value ${JSON.stringify(value)}`);
|
|
3501
|
+
}
|
|
3502
|
+
return true;
|
|
3503
|
+
}
|
|
3504
|
+
async clearPhoneCountryMarkers() {
|
|
3505
|
+
if (!this.page)
|
|
3506
|
+
return;
|
|
3507
|
+
await this.page
|
|
3508
|
+
.evaluate(() => {
|
|
3509
|
+
document.querySelectorAll("[data-ts-phone-cc]").forEach((el) => {
|
|
3510
|
+
el.removeAttribute("data-ts-phone-cc");
|
|
3511
|
+
});
|
|
3512
|
+
})
|
|
3513
|
+
.catch(() => { });
|
|
3514
|
+
}
|
|
3515
|
+
async markComboboxPreexistingElements() {
|
|
3516
|
+
if (!this.page)
|
|
3517
|
+
throw new Error("Browser not started");
|
|
3518
|
+
await this.page.evaluate(() => {
|
|
3519
|
+
const visible = (el) => {
|
|
3520
|
+
const rect = el.getBoundingClientRect();
|
|
3521
|
+
if (rect.width < 2 || rect.height < 2)
|
|
3522
|
+
return false;
|
|
3523
|
+
const style = getComputedStyle(el);
|
|
3524
|
+
return (style.display !== "none" &&
|
|
3525
|
+
style.visibility !== "hidden" &&
|
|
3526
|
+
parseFloat(style.opacity || "1") > 0.01);
|
|
3527
|
+
};
|
|
3528
|
+
const popupSelector = '[role="listbox"],[role="menu"],[role="dialog"],[id*="listbox" i],[id*="dropdown" i],[id*="popover" i],[id*="menu" i],[id*="options" i],[class*="listbox" i],[class*="dropdown" i],[class*="popover" i],[class*="menu" i],[class*="options" i]';
|
|
3529
|
+
document
|
|
3530
|
+
.querySelectorAll(popupSelector)
|
|
3531
|
+
.forEach((el) => visible(el) && el.setAttribute("data-ts-select-preexisting-popup", "1"));
|
|
3532
|
+
});
|
|
3533
|
+
}
|
|
3534
|
+
async refreshComboboxMarkers(triggerSelector) {
|
|
3535
|
+
if (!this.page)
|
|
3536
|
+
throw new Error("Browser not started");
|
|
3537
|
+
await this.page
|
|
3538
|
+
.locator(triggerSelector)
|
|
3539
|
+
.first()
|
|
3540
|
+
.evaluate((trigger) => {
|
|
3541
|
+
const visible = (el) => {
|
|
3542
|
+
const rect = el.getBoundingClientRect();
|
|
3543
|
+
if (rect.width < 2 || rect.height < 2)
|
|
3544
|
+
return false;
|
|
3545
|
+
const style = getComputedStyle(el);
|
|
3546
|
+
return (style.display !== "none" &&
|
|
3547
|
+
style.visibility !== "hidden" &&
|
|
3548
|
+
parseFloat(style.opacity || "1") > 0.01);
|
|
3549
|
+
};
|
|
3550
|
+
document
|
|
3551
|
+
.querySelectorAll("[data-ts-select-popup],[data-ts-select-option-tier]")
|
|
3552
|
+
.forEach((el) => {
|
|
3553
|
+
el.removeAttribute("data-ts-select-popup");
|
|
3554
|
+
el.removeAttribute("data-ts-select-option-tier");
|
|
3555
|
+
});
|
|
3556
|
+
const popupSelector = '[role="listbox"],[role="menu"],[role="dialog"],[id*="listbox" i],[id*="dropdown" i],[id*="popover" i],[id*="menu" i],[id*="options" i],[class*="listbox" i],[class*="dropdown" i],[class*="popover" i],[class*="menu" i],[class*="options" i]';
|
|
3557
|
+
const controlledPopups = [];
|
|
3558
|
+
for (const attr of ["aria-controls", "aria-owns"]) {
|
|
3559
|
+
for (const id of (trigger.getAttribute(attr) ?? "").split(/\s+/).filter(Boolean)) {
|
|
3560
|
+
const popup = trigger.ownerDocument.getElementById(id);
|
|
3561
|
+
if (popup !== null && visible(popup) && !controlledPopups.includes(popup)) {
|
|
3562
|
+
controlledPopups.push(popup);
|
|
3563
|
+
}
|
|
3564
|
+
}
|
|
3565
|
+
}
|
|
3566
|
+
const openedPopups = [];
|
|
3567
|
+
trigger.ownerDocument.querySelectorAll(popupSelector).forEach((el) => {
|
|
3568
|
+
if (!el.hasAttribute("data-ts-select-preexisting-popup") &&
|
|
3569
|
+
visible(el) &&
|
|
3570
|
+
!openedPopups.includes(el)) {
|
|
3571
|
+
openedPopups.push(el);
|
|
3572
|
+
}
|
|
3573
|
+
});
|
|
3574
|
+
const singlePopup = (candidates) => {
|
|
3575
|
+
const semantic = candidates.filter((el) => el.matches('[role="listbox"],[role="dialog"],[role="menu"]'));
|
|
3576
|
+
const pool = semantic.length > 0 ? semantic : candidates;
|
|
3577
|
+
const innermost = pool.filter((candidate) => !pool.some((other) => other !== candidate && candidate.contains(other)));
|
|
3578
|
+
return innermost.length === 1 ? innermost[0] : undefined;
|
|
3579
|
+
};
|
|
3580
|
+
const popup = controlledPopups.length > 0 ? singlePopup(controlledPopups) : singlePopup(openedPopups);
|
|
3581
|
+
popup?.setAttribute("data-ts-select-popup", "1");
|
|
3582
|
+
const optionSelectors = [
|
|
3583
|
+
'[role="option"]',
|
|
3584
|
+
'[role="menuitem"]',
|
|
3585
|
+
'[role="menuitemradio"]',
|
|
3586
|
+
"mat-option",
|
|
3587
|
+
".mat-mdc-option",
|
|
3588
|
+
'[id^="react-select-"][role*="menu"]',
|
|
3589
|
+
'[role="listbox"] li',
|
|
3590
|
+
];
|
|
3591
|
+
optionSelectors.forEach((selector, tier) => {
|
|
3592
|
+
trigger.ownerDocument.querySelectorAll(selector).forEach((el) => {
|
|
3593
|
+
if (popup !== undefined && visible(el) && (popup === el || popup.contains(el))) {
|
|
3594
|
+
el.setAttribute("data-ts-select-option-tier", String(tier));
|
|
3595
|
+
}
|
|
3596
|
+
});
|
|
3597
|
+
});
|
|
3598
|
+
});
|
|
3599
|
+
}
|
|
3600
|
+
async clearComboboxMarkers() {
|
|
3601
|
+
if (!this.page)
|
|
3602
|
+
return;
|
|
3603
|
+
await this.page
|
|
3604
|
+
.evaluate(() => {
|
|
3605
|
+
document
|
|
3606
|
+
.querySelectorAll("[data-ts-select-preexisting-popup],[data-ts-select-popup],[data-ts-select-option-tier]")
|
|
3607
|
+
.forEach((el) => {
|
|
3608
|
+
el.removeAttribute("data-ts-select-preexisting-popup");
|
|
3609
|
+
el.removeAttribute("data-ts-select-popup");
|
|
3610
|
+
el.removeAttribute("data-ts-select-option-tier");
|
|
3611
|
+
});
|
|
3612
|
+
})
|
|
3613
|
+
.catch(() => { });
|
|
3614
|
+
}
|
|
3080
3615
|
async selectFromCombobox(triggerSelector, optionMatcher) {
|
|
3081
3616
|
if (!this.page)
|
|
3082
3617
|
throw new Error("Browser not started");
|
|
@@ -3091,67 +3626,36 @@ export class BrowserController {
|
|
|
3091
3626
|
// label to its associated input here so downstream tiers (the
|
|
3092
3627
|
// keyboard fallback in particular) actually see an input target.
|
|
3093
3628
|
const normalizedSelector = await this.resolveLabelToInput(triggerSelector);
|
|
3094
|
-
await this.
|
|
3095
|
-
|
|
3096
|
-
|
|
3097
|
-
|
|
3098
|
-
'[
|
|
3099
|
-
|
|
3100
|
-
|
|
3101
|
-
|
|
3102
|
-
|
|
3103
|
-
|
|
3104
|
-
|
|
3105
|
-
|
|
3106
|
-
|
|
3107
|
-
const
|
|
3108
|
-
|
|
3109
|
-
|
|
3629
|
+
await this.markComboboxPreexistingElements();
|
|
3630
|
+
try {
|
|
3631
|
+
await this.humanClick(normalizedSelector);
|
|
3632
|
+
await this.refreshComboboxMarkers(normalizedSelector);
|
|
3633
|
+
let popup = this.page.locator('[data-ts-select-popup="1"]').first();
|
|
3634
|
+
if ((await popup.count()) === 0) {
|
|
3635
|
+
await this.openComboboxWithKeyboard(normalizedSelector);
|
|
3636
|
+
await this.refreshComboboxMarkers(normalizedSelector);
|
|
3637
|
+
popup = this.page.locator('[data-ts-select-popup="1"]').first();
|
|
3638
|
+
}
|
|
3639
|
+
if ((await popup.count()) === 0) {
|
|
3640
|
+
throw new Error(`combobox ${triggerSelector}: no single opened popup could be resolved`);
|
|
3641
|
+
}
|
|
3642
|
+
const options = this.page.locator("[data-ts-select-option-tier]");
|
|
3643
|
+
let target = options.first();
|
|
3644
|
+
if (optionMatcher !== undefined) {
|
|
3645
|
+
const matching = options.filter({ hasText: optionMatcher });
|
|
3646
|
+
if ((await matching.count()) === 0) {
|
|
3647
|
+
throw new Error(`combobox ${triggerSelector}: no option matched ${JSON.stringify(optionMatcher)}`);
|
|
3648
|
+
}
|
|
3649
|
+
target = matching.first();
|
|
3110
3650
|
}
|
|
3111
|
-
|
|
3112
|
-
|
|
3651
|
+
else if ((await options.count()) === 0) {
|
|
3652
|
+
throw new Error(`combobox ${triggerSelector}: opened popup has no actionable options`);
|
|
3113
3653
|
}
|
|
3114
|
-
|
|
3115
|
-
if (count === 0)
|
|
3116
|
-
continue;
|
|
3117
|
-
await this.pickComboboxOption(locator, optionMatcher);
|
|
3118
|
-
return;
|
|
3119
|
-
}
|
|
3120
|
-
// 0.8.2-rc.11 — keyboard-driven react-select fallback. Sentry's
|
|
3121
|
-
// permission-grid combobox (Project--permission, Team--permission,
|
|
3122
|
-
// …) is a react-select 5 instance: clicking the inner <input> only
|
|
3123
|
-
// focuses it; the menu opens on keyboard activity. The standard
|
|
3124
|
-
// pattern is: Alt+Down (or just type a character) to open + filter,
|
|
3125
|
-
// then Enter to commit. Try Alt+Down first so an instance with
|
|
3126
|
-
// visible options but no role="option" still works; then if a
|
|
3127
|
-
// matcher was given, type-to-filter + Enter so a hidden listbox
|
|
3128
|
-
// narrows directly to the right option.
|
|
3129
|
-
if (await this.tryReactSelectKeyboardPick(normalizedSelector, optionMatcher)) {
|
|
3130
|
-
return;
|
|
3654
|
+
await this.clickComboboxOption(target);
|
|
3131
3655
|
}
|
|
3132
|
-
|
|
3133
|
-
|
|
3134
|
-
// told us WHICH option to pick — without a matcher, "first text
|
|
3135
|
-
// on the page" would click unrelated UI.
|
|
3136
|
-
if (optionMatcher !== undefined) {
|
|
3137
|
-
const byText = this.page.getByText(optionMatcher, { exact: false }).first();
|
|
3138
|
-
triedDescriptors.push(`text="${optionMatcher}"`);
|
|
3139
|
-
try {
|
|
3140
|
-
await byText.waitFor({ state: "visible", timeout: 2000 });
|
|
3141
|
-
await this.humanClickLocator(byText);
|
|
3142
|
-
await this.wait(0.5);
|
|
3143
|
-
return;
|
|
3144
|
-
}
|
|
3145
|
-
catch {
|
|
3146
|
-
// not found — fall through to error
|
|
3147
|
-
}
|
|
3656
|
+
finally {
|
|
3657
|
+
await this.clearComboboxMarkers();
|
|
3148
3658
|
}
|
|
3149
|
-
throw new Error(`combobox ${triggerSelector}` +
|
|
3150
|
-
(normalizedSelector !== triggerSelector ? ` (normalized to ${normalizedSelector})` : "") +
|
|
3151
|
-
`: no options found after click. ` +
|
|
3152
|
-
`Tried: ${triedDescriptors.join(", ")}. ` +
|
|
3153
|
-
`The trigger may not have opened a popover, or the popover uses ` +
|
|
3154
|
-
`an option pattern this executor doesn't recognize.`);
|
|
3155
3659
|
}
|
|
3156
3660
|
// 0.8.2-rc.11 — resolve a `<label for="X">` selector to `#X` so the
|
|
3157
3661
|
// executor lands on the actual input rather than the label decoration.
|
|
@@ -3203,120 +3707,22 @@ export class BrowserController {
|
|
|
3203
3707
|
return selector;
|
|
3204
3708
|
}
|
|
3205
3709
|
}
|
|
3206
|
-
|
|
3207
|
-
// trigger is the inner <input>; opening the menu via mouse click
|
|
3208
|
-
// alone isn't reliable on every react-select instance (Sentry's
|
|
3209
|
-
// permission grid). Sequence:
|
|
3210
|
-
// 1. focus the trigger (the click already happened in
|
|
3211
|
-
// selectFromCombobox, but a defensive .focus() handles the
|
|
3212
|
-
// case where the click went to a sibling overlay).
|
|
3213
|
-
// 2. press Alt+ArrowDown — react-select binds this to open the
|
|
3214
|
-
// menu and select the first option.
|
|
3215
|
-
// 3. if a matcher was given, type its first 1-3 letters to filter
|
|
3216
|
-
// the menu down to the right option, then press Enter to
|
|
3217
|
-
// commit.
|
|
3218
|
-
// 4. if no matcher, ArrowDown was already issued — press Enter to
|
|
3219
|
-
// commit the first option.
|
|
3220
|
-
// Verify via the input's aria-activedescendant or value attribute
|
|
3221
|
-
// changing (react-select updates one or the other on selection).
|
|
3222
|
-
// Returns true on success, false when the page didn't react.
|
|
3223
|
-
async tryReactSelectKeyboardPick(triggerSelector, optionMatcher) {
|
|
3710
|
+
async openComboboxWithKeyboard(triggerSelector) {
|
|
3224
3711
|
if (!this.page)
|
|
3225
3712
|
throw new Error("Browser not started");
|
|
3226
|
-
const
|
|
3227
|
-
try {
|
|
3228
|
-
const tagName = await triggerLocator.first().evaluate((node) => node.tagName.toLowerCase());
|
|
3229
|
-
// Limit this path to input-typed triggers; native <select> and
|
|
3230
|
-
// <button role="combobox"> are handled by other tiers. The
|
|
3231
|
-
// selectFromCombobox caller has already returned for matching
|
|
3232
|
-
// [role="option"] tiers, so we only reach here on patterns where
|
|
3233
|
-
// the trigger is an input.
|
|
3234
|
-
if (tagName !== "input")
|
|
3235
|
-
return false;
|
|
3236
|
-
}
|
|
3237
|
-
catch {
|
|
3238
|
-
return false;
|
|
3239
|
-
}
|
|
3240
|
-
try {
|
|
3241
|
-
await triggerLocator.first().focus({ timeout: 1500 });
|
|
3242
|
-
}
|
|
3243
|
-
catch {
|
|
3244
|
-
return false;
|
|
3245
|
-
}
|
|
3246
|
-
// Snapshot the input's relevant attributes BEFORE opening so we
|
|
3247
|
-
// can verify that the pick actually committed.
|
|
3248
|
-
const before = await triggerLocator
|
|
3249
|
-
.first()
|
|
3250
|
-
.evaluate((node) => ({
|
|
3251
|
-
activedescendant: node.getAttribute("aria-activedescendant") ?? "",
|
|
3252
|
-
value: node instanceof HTMLInputElement ? node.value : "",
|
|
3253
|
-
// react-select 5 mirrors the selected value into the closest
|
|
3254
|
-
// .css-{hash}-singleValue node; grab the trigger's surrounding
|
|
3255
|
-
// text so a successful pick produces an observable change.
|
|
3256
|
-
surroundingText: node.parentElement?.parentElement?.parentElement?.textContent ?? "",
|
|
3257
|
-
}))
|
|
3258
|
-
.catch(() => ({ activedescendant: "", value: "", surroundingText: "" }));
|
|
3259
|
-
// Press Alt+ArrowDown to open + highlight the first option, then
|
|
3260
|
-
// if a matcher exists, type to filter, then Enter.
|
|
3713
|
+
const trigger = this.page.locator(triggerSelector).first();
|
|
3261
3714
|
try {
|
|
3715
|
+
if ((await trigger.evaluate((node) => node.tagName.toLowerCase())) !== "input")
|
|
3716
|
+
return;
|
|
3717
|
+
await trigger.focus({ timeout: 1500 });
|
|
3262
3718
|
await this.page.keyboard.press("Alt+ArrowDown");
|
|
3719
|
+
await this.wait(0.4);
|
|
3263
3720
|
}
|
|
3264
3721
|
catch {
|
|
3265
|
-
return
|
|
3266
|
-
}
|
|
3267
|
-
// Wait briefly for the menu to render.
|
|
3268
|
-
await this.wait(0.4);
|
|
3269
|
-
if (optionMatcher !== undefined && optionMatcher.length > 0) {
|
|
3270
|
-
// Type a few characters to filter; react-select narrows on each
|
|
3271
|
-
// keystroke. Capping at 6 keeps the input from overshooting on
|
|
3272
|
-
// a long matcher when the first few characters already narrow
|
|
3273
|
-
// to a single option ("Admin" → typing "Adm" is enough).
|
|
3274
|
-
const typed = optionMatcher.slice(0, 6);
|
|
3275
|
-
try {
|
|
3276
|
-
await triggerLocator.first().pressSequentially(typed, { delay: 25 });
|
|
3277
|
-
}
|
|
3278
|
-
catch {
|
|
3279
|
-
return false;
|
|
3280
|
-
}
|
|
3281
|
-
await this.wait(0.35);
|
|
3282
|
-
}
|
|
3283
|
-
try {
|
|
3284
|
-
await this.page.keyboard.press("Enter");
|
|
3285
|
-
}
|
|
3286
|
-
catch {
|
|
3287
|
-
return false;
|
|
3722
|
+
return;
|
|
3288
3723
|
}
|
|
3289
|
-
await this.wait(0.5);
|
|
3290
|
-
const after = await triggerLocator
|
|
3291
|
-
.first()
|
|
3292
|
-
.evaluate((node) => ({
|
|
3293
|
-
activedescendant: node.getAttribute("aria-activedescendant") ?? "",
|
|
3294
|
-
value: node instanceof HTMLInputElement ? node.value : "",
|
|
3295
|
-
surroundingText: node.parentElement?.parentElement?.parentElement?.textContent ?? "",
|
|
3296
|
-
}))
|
|
3297
|
-
.catch(() => ({ activedescendant: "", value: "", surroundingText: "" }));
|
|
3298
|
-
// A successful pick produces at least one observable change.
|
|
3299
|
-
// react-select clears the input's value once a selection commits
|
|
3300
|
-
// (the chosen label moves into a sibling singleValue node), so the
|
|
3301
|
-
// surrounding-text diff is the strongest signal.
|
|
3302
|
-
if (before.surroundingText !== after.surroundingText)
|
|
3303
|
-
return true;
|
|
3304
|
-
if (before.activedescendant !== after.activedescendant)
|
|
3305
|
-
return true;
|
|
3306
|
-
if (before.value !== after.value)
|
|
3307
|
-
return true;
|
|
3308
|
-
return false;
|
|
3309
3724
|
}
|
|
3310
|
-
|
|
3311
|
-
// candidates. Matcher → filter by hasText (case-insensitive by
|
|
3312
|
-
// default in Playwright). No matcher → first.
|
|
3313
|
-
async pickComboboxOption(options, matcher) {
|
|
3314
|
-
let target = options.first();
|
|
3315
|
-
if (matcher !== undefined) {
|
|
3316
|
-
const filtered = options.filter({ hasText: matcher });
|
|
3317
|
-
if ((await filtered.count()) > 0)
|
|
3318
|
-
target = filtered.first();
|
|
3319
|
-
}
|
|
3725
|
+
async clickComboboxOption(target) {
|
|
3320
3726
|
// cmdk (the command-menu library) does NOT commit a selection from the
|
|
3321
3727
|
// bot's humanized page.mouse.click(x, y): cmdk re-renders + re-orders its
|
|
3322
3728
|
// list as the search filters, so the cached click coordinates land on the
|
|
@@ -3337,11 +3743,9 @@ export class BrowserController {
|
|
|
3337
3743
|
// full trusted pointer/mouse sequence at the element's center — what
|
|
3338
3744
|
// cmdk's onSelect actually listens for.
|
|
3339
3745
|
await target.click({ timeout: 5000 }).catch(async () => {
|
|
3340
|
-
|
|
3341
|
-
|
|
3342
|
-
await target.dispatchEvent("
|
|
3343
|
-
await target.dispatchEvent("pointerup").catch(() => { });
|
|
3344
|
-
await this.page?.keyboard.press("Enter").catch(() => { });
|
|
3746
|
+
await target.dispatchEvent("pointerdown");
|
|
3747
|
+
await target.dispatchEvent("pointerup");
|
|
3748
|
+
await target.dispatchEvent("click");
|
|
3345
3749
|
});
|
|
3346
3750
|
await this.wait(0.5);
|
|
3347
3751
|
return;
|
|
@@ -6693,11 +7097,37 @@ export class BrowserController {
|
|
|
6693
7097
|
return { topmost: false, occludedBy: null };
|
|
6694
7098
|
const x = Math.min(window.innerWidth - 1, Math.max(0, r.left + r.width / 2));
|
|
6695
7099
|
const y = Math.min(window.innerHeight - 1, Math.max(0, r.top + r.height / 2));
|
|
6696
|
-
|
|
7100
|
+
let top = document.elementFromPoint(x, y);
|
|
6697
7101
|
if (top === null)
|
|
6698
7102
|
return { topmost: false, occludedBy: null };
|
|
7103
|
+
// document.elementFromPoint returns the shadow HOST, not the control
|
|
7104
|
+
// nested in its open shadow root — so a shadow-DOM CTA (Casetify's
|
|
7105
|
+
// Add-to-Cart web component) hit-tested against its own host would be
|
|
7106
|
+
// reported occludedBy that host and topmost:false, and the host agent
|
|
7107
|
+
// would skip a button nothing actually covers. Re-hit-test inside each
|
|
7108
|
+
// open shadow root at the same point to reach the deepest composed
|
|
7109
|
+
// element, matching what the user's pointer would strike. Closed roots
|
|
7110
|
+
// yield a null shadowRoot and the descent stops — same as the DOM.
|
|
7111
|
+
while (top.shadowRoot !== null) {
|
|
7112
|
+
const deeper = top.shadowRoot.elementFromPoint(x, y);
|
|
7113
|
+
if (deeper === null || deeper === top)
|
|
7114
|
+
break;
|
|
7115
|
+
top = deeper;
|
|
7116
|
+
}
|
|
6699
7117
|
if (top === el || el.contains(top))
|
|
6700
7118
|
return { topmost: true, occludedBy: null };
|
|
7119
|
+
let owner = top;
|
|
7120
|
+
while (owner !== null) {
|
|
7121
|
+
if (owner === el)
|
|
7122
|
+
return { topmost: true, occludedBy: null };
|
|
7123
|
+
const assignedSlot = owner instanceof Element || owner instanceof Text ? owner.assignedSlot : null;
|
|
7124
|
+
if (assignedSlot !== null) {
|
|
7125
|
+
owner = assignedSlot;
|
|
7126
|
+
continue;
|
|
7127
|
+
}
|
|
7128
|
+
const parent = owner.parentNode;
|
|
7129
|
+
owner = parent instanceof ShadowRoot ? parent.host : parent;
|
|
7130
|
+
}
|
|
6701
7131
|
return { topmost: false, occludedBy: regionName(regionFor(top)) ?? elementKind(top) };
|
|
6702
7132
|
};
|
|
6703
7133
|
// N1 onboarding-wizard cards (2026-06-08). Chakra/React card pickers
|
|
@@ -7974,6 +8404,53 @@ export function isBareClickableCardTag(tag) {
|
|
|
7974
8404
|
t === "label" ||
|
|
7975
8405
|
t.includes("-"));
|
|
7976
8406
|
}
|
|
8407
|
+
// Classify the operator's single country argument. WHY exact-signal buckets:
|
|
8408
|
+
// "+1" is a dial code, "US" an ISO2, "United States" a name — matching each
|
|
8409
|
+
// against the wrong DOM attribute (e.g. substring-matching "US" against option
|
|
8410
|
+
// text) produces false hits, so we commit to one interpretation per input.
|
|
8411
|
+
export function classifyPhoneCountryQuery(raw) {
|
|
8412
|
+
const t = raw.trim();
|
|
8413
|
+
if (t.length === 0)
|
|
8414
|
+
return {};
|
|
8415
|
+
// Dial code: an optional leading "+" then 1-4 digits and nothing else.
|
|
8416
|
+
if (/^\+?\d{1,4}$/.test(t))
|
|
8417
|
+
return { dialCode: t.replace(/\D/g, "") };
|
|
8418
|
+
// ISO2: exactly two ASCII letters. Almost always an alpha-2 country code
|
|
8419
|
+
// ("JP", "US"); a two-letter country NAME doesn't exist, so this is safe.
|
|
8420
|
+
if (/^[A-Za-z]{2}$/.test(t))
|
|
8421
|
+
return { iso2: t.toUpperCase() };
|
|
8422
|
+
// Otherwise a free-text country name for case-insensitive substring match.
|
|
8423
|
+
return { name: t.toLowerCase() };
|
|
8424
|
+
}
|
|
8425
|
+
// Decide whether a picker option satisfies the query. Exact-signal queries
|
|
8426
|
+
// (iso2/dialCode) match ONLY against the corresponding structured field (with
|
|
8427
|
+
// a dial-code fallback to a "+NN" embedded in native option text). A name query is a
|
|
8428
|
+
// case-insensitive substring test against the option's visible text.
|
|
8429
|
+
export function phoneCountryOptionMatches(query, opt) {
|
|
8430
|
+
const digits = (s) => s.replace(/\D/g, "");
|
|
8431
|
+
if (query.iso2 !== undefined) {
|
|
8432
|
+
return opt.iso2 !== undefined && opt.iso2.toUpperCase() === query.iso2;
|
|
8433
|
+
}
|
|
8434
|
+
if (query.dialCode !== undefined) {
|
|
8435
|
+
if (opt.dialCode !== undefined && digits(opt.dialCode) === query.dialCode)
|
|
8436
|
+
return true;
|
|
8437
|
+
if (opt.text !== undefined) {
|
|
8438
|
+
const m = opt.text.match(/\+(\d{1,4})/);
|
|
8439
|
+
if (m !== null && m[1] === query.dialCode)
|
|
8440
|
+
return true;
|
|
8441
|
+
}
|
|
8442
|
+
return false;
|
|
8443
|
+
}
|
|
8444
|
+
if (query.name !== undefined) {
|
|
8445
|
+
return opt.text !== undefined && opt.text.toLowerCase().includes(query.name);
|
|
8446
|
+
}
|
|
8447
|
+
return false;
|
|
8448
|
+
}
|
|
8449
|
+
// Index of the first option matching the query, or -1. Extracted so the
|
|
8450
|
+
// pick-a-row decision is unit-tested independently of the DOM read.
|
|
8451
|
+
export function pickPhoneCountryOption(query, options) {
|
|
8452
|
+
return options.findIndex((o) => phoneCountryOptionMatches(query, o));
|
|
8453
|
+
}
|
|
7977
8454
|
//
|
|
7978
8455
|
// Exported for unit testing — the scoring is the load-bearing logic.
|
|
7979
8456
|
export function pickSubmitButtonIndex(texts) {
|