@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.
@@ -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 !== null) {
3019
- chosenValue = matched.value;
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
- // F11 (+rc.7 hardening): click a combobox trigger, wait for the
3047
- // listbox to open, click an option.
3048
- //
3049
- // Tries option-selector patterns in priority order each tier
3050
- // targets one combobox-library convention. The text-based final
3051
- // tier catches libraries that ship NO ARIA roles at all.
3052
- //
3053
- // 1. [role=option] — Radix, Headless UI, React Aria, cmdk
3054
- // 2. [role=menuitem] — ARIA menu pattern (libs that model
3055
- // a dropdown as a menu)
3056
- // 3. [role=menuitemradio] — react-select's per-row permission
3057
- // picker shape (rc.15 Sentry's
3058
- // token-create grid). Identical shape
3059
- // to menuitem for selection purposes,
3060
- // distinct role string. Without this
3061
- // tier Sentry's "Team permission =
3062
- // Admin" never resolves and the loop
3063
- // burns the post-verify budget.
3064
- // 4. [id^="react-select-"] — defense-in-depth for any react-
3065
- // select instance that drops the
3066
- // role attribute. The id prefix is
3067
- // baked into the library and is the
3068
- // most stable signal short of the
3069
- // role.
3070
- // 5. [role=listbox] li — listbox container without role
3071
- // attribute on its children
3072
- // 6. text-based (matcher only) — after the trigger click, any newly-
3073
- // visible element whose text matches
3074
- // the planner-supplied label is
3075
- // almost certainly the option. Only
3076
- // enabled when a matcher exists,
3077
- // since "first text on the page"
3078
- // with no matcher would catch
3079
- // unrelated UI text.
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.humanClick(normalizedSelector);
3095
- const patternSelectors = [
3096
- '[role="option"]:visible',
3097
- '[role="menuitem"]:visible',
3098
- '[role="menuitemradio"]:visible',
3099
- "mat-option:visible",
3100
- ".mat-mdc-option:visible",
3101
- '[id^="react-select-"][role*="menu"]:visible',
3102
- '[role="listbox"]:visible li:visible',
3103
- ];
3104
- const triedDescriptors = [];
3105
- for (const sel of patternSelectors) {
3106
- triedDescriptors.push(sel);
3107
- const locator = this.page.locator(sel);
3108
- try {
3109
- await locator.first().waitFor({ state: "visible", timeout: 1500 });
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
- catch {
3112
- continue;
3651
+ else if ((await options.count()) === 0) {
3652
+ throw new Error(`combobox ${triggerSelector}: opened popup has no actionable options`);
3113
3653
  }
3114
- const count = await locator.count();
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
- triedDescriptors.push("react-select keyboard (Alt+Down, type-to-filter, Enter)");
3133
- // ARIA tiers all empty. Text-based fallback, only if the planner
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
- // 0.8.2-rc.11 — keyboard-driven react-select interaction. The
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 triggerLocator = this.page.locator(triggerSelector);
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 false;
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
- // F11: pick an option from a Playwright Locator already-narrowed to
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
- // Backup: dispatch the pointer pair directly, then Enter (the cmdk
3341
- // input is focused after type-to-filter and highlights this item).
3342
- await target.dispatchEvent("pointerdown").catch(() => { });
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
- const top = document.elementFromPoint(x, y);
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) {