@trusty-squire/mcp 1.1.8 → 1.1.9-rc.2

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.
Files changed (58) hide show
  1. package/README.md +13 -5
  2. package/dist/bot/browser.d.ts +96 -1
  3. package/dist/bot/browser.d.ts.map +1 -1
  4. package/dist/bot/browser.js +420 -1
  5. package/dist/bot/browser.js.map +1 -1
  6. package/dist/bot/operator-recipe.d.ts +27 -975
  7. package/dist/bot/operator-recipe.d.ts.map +1 -1
  8. package/dist/bot/operator-recipe.js +88 -237
  9. package/dist/bot/operator-recipe.js.map +1 -1
  10. package/dist/bot/pay-operator.d.ts +2 -0
  11. package/dist/bot/pay-operator.d.ts.map +1 -1
  12. package/dist/bot/pay-operator.js +9 -5
  13. package/dist/bot/pay-operator.js.map +1 -1
  14. package/dist/bot/provision-session.d.ts +44 -2
  15. package/dist/bot/provision-session.d.ts.map +1 -1
  16. package/dist/bot/provision-session.js +383 -4
  17. package/dist/bot/provision-session.js.map +1 -1
  18. package/dist/skill-registry-client.d.ts +57 -0
  19. package/dist/skill-registry-client.d.ts.map +1 -1
  20. package/dist/skill-registry-client.js +156 -7
  21. package/dist/skill-registry-client.js.map +1 -1
  22. package/dist/tools/provision-drive.d.ts +73 -26
  23. package/dist/tools/provision-drive.d.ts.map +1 -1
  24. package/dist/tools/provision-drive.js +224 -10
  25. package/dist/tools/provision-drive.js.map +1 -1
  26. package/package.json +3 -1
  27. package/dist/eval/replay-harness/corpus.d.ts +0 -7
  28. package/dist/eval/replay-harness/corpus.d.ts.map +0 -1
  29. package/dist/eval/replay-harness/corpus.js +0 -183
  30. package/dist/eval/replay-harness/corpus.js.map +0 -1
  31. package/dist/eval/replay-harness/har-mutate.d.ts +0 -36
  32. package/dist/eval/replay-harness/har-mutate.d.ts.map +0 -1
  33. package/dist/eval/replay-harness/har-mutate.js +0 -119
  34. package/dist/eval/replay-harness/har-mutate.js.map +0 -1
  35. package/dist/eval/replay-harness/har-substrate.d.ts +0 -16
  36. package/dist/eval/replay-harness/har-substrate.d.ts.map +0 -1
  37. package/dist/eval/replay-harness/har-substrate.js +0 -99
  38. package/dist/eval/replay-harness/har-substrate.js.map +0 -1
  39. package/dist/eval/replay-harness/index.d.ts +0 -8
  40. package/dist/eval/replay-harness/index.d.ts.map +0 -1
  41. package/dist/eval/replay-harness/index.js +0 -8
  42. package/dist/eval/replay-harness/index.js.map +0 -1
  43. package/dist/eval/replay-harness/metrics.d.ts +0 -9
  44. package/dist/eval/replay-harness/metrics.d.ts.map +0 -1
  45. package/dist/eval/replay-harness/metrics.js +0 -117
  46. package/dist/eval/replay-harness/metrics.js.map +0 -1
  47. package/dist/eval/replay-harness/reporter.d.ts +0 -4
  48. package/dist/eval/replay-harness/reporter.d.ts.map +0 -1
  49. package/dist/eval/replay-harness/reporter.js +0 -62
  50. package/dist/eval/replay-harness/reporter.js.map +0 -1
  51. package/dist/eval/replay-harness/runner.d.ts +0 -36
  52. package/dist/eval/replay-harness/runner.d.ts.map +0 -1
  53. package/dist/eval/replay-harness/runner.js +0 -167
  54. package/dist/eval/replay-harness/runner.js.map +0 -1
  55. package/dist/eval/replay-harness/types.d.ts +0 -135
  56. package/dist/eval/replay-harness/types.d.ts.map +0 -1
  57. package/dist/eval/replay-harness/types.js +0 -2
  58. package/dist/eval/replay-harness/types.js.map +0 -1
@@ -269,6 +269,46 @@ function parseCheckoutAmountResult(texts, fallbackCurrency) {
269
269
  export function parseCheckoutAmount(texts, fallbackCurrency) {
270
270
  return parseCheckoutAmountResult(texts, fallbackCurrency).amount;
271
271
  }
272
+ /**
273
+ * Like parseCheckoutAmount but returns every currency-guard-clean match
274
+ * instead of the first — checkout review pages can show a pre-shipping
275
+ * subtotal before the final labeled total, so the caller needs the full
276
+ * sequence to pick the settled one. Reuses the same regex and currency-guard
277
+ * helpers as parseCheckoutAmountResult (unresolved-currency and
278
+ * fallback-scale-mismatch matches are skipped identically), just without the
279
+ * single-result early return.
280
+ */
281
+ export function parseCheckoutAmounts(texts, fallbackCurrency) {
282
+ const amounts = [];
283
+ for (const text of texts) {
284
+ checkoutTotalPattern.lastIndex = 0;
285
+ for (const match of text.matchAll(checkoutTotalPattern)) {
286
+ const prefix = classifyCheckoutCurrencyToken(match[1]);
287
+ const symbol = classifyCheckoutCurrencyToken(match[2]);
288
+ const suffix = classifyCheckoutCurrencyToken(match[4]);
289
+ if (prefix.unresolved || symbol.unresolved || suffix.unresolved)
290
+ continue;
291
+ const pageCurrency = prefix.currency ?? suffix.currency ?? symbol.currency;
292
+ const currency = (pageCurrency ?? fallbackCurrency)?.toUpperCase();
293
+ if (currency === undefined || !/^[A-Z]{3}$/.test(currency))
294
+ continue;
295
+ const minorDigits = currencyMinorDigits(currency);
296
+ if (pageCurrency === undefined &&
297
+ fallbackCurrencyScaleMismatches(match[3] ?? "", minorDigits)) {
298
+ continue;
299
+ }
300
+ const amount = parseDisplayedNumber(match[3] ?? "", minorDigits);
301
+ if (amount === null)
302
+ continue;
303
+ const scale = 10 ** minorDigits;
304
+ const minor = Math.round(amount * scale);
305
+ if (Math.abs(amount * scale - minor) > 1e-6)
306
+ continue;
307
+ amounts.push({ amount_cents: minor, currency });
308
+ }
309
+ }
310
+ return amounts;
311
+ }
272
312
  function merchantFromPage(title, siteName, url) {
273
313
  if (siteName.trim().length > 0)
274
314
  return siteName.trim().slice(0, 256);
@@ -950,6 +990,9 @@ export class BrowserController {
950
990
  // different signal from the same failure on a raw datacenter IP.
951
991
  proxyServer = null;
952
992
  profileDir;
993
+ // The replay harness owns this context so it can route the storefront from a
994
+ // HAR, then remove that route before checkout becomes live.
995
+ harnessAttachedPage = false;
953
996
  // T6/T7 — OAuth handshake bookkeeping. When startOAuth() adopts a
954
997
  // popup window as the active page, the original product page is
955
998
  // parked here so settleAfterOAuth() can switch back to it once the
@@ -980,6 +1023,16 @@ export class BrowserController {
980
1023
  this.proxyOverride =
981
1024
  opts.proxyUrl !== undefined && opts.proxyUrl.trim().length > 0 ? opts.proxyUrl.trim() : null;
982
1025
  }
1026
+ /** Attach normal controller behavior to a harness-owned Playwright page. */
1027
+ static fromHarnessPage(page) {
1028
+ const controller = new BrowserController({ humanize: false });
1029
+ controller.context = page.context();
1030
+ controller.page = page;
1031
+ controller.primaryPage = page;
1032
+ controller.harnessAttachedPage = true;
1033
+ controller.launchedMode = "headless";
1034
+ return controller;
1035
+ }
983
1036
  // Per-launch egress override (verify-fleet identities each get their own IP).
984
1037
  // null → use the env-global proxy. See resolveProxy().
985
1038
  proxyOverride;
@@ -3931,6 +3984,167 @@ export class BrowserController {
3931
3984
  await this.humanClickLocator(target);
3932
3985
  await this.wait(0.5);
3933
3986
  }
3987
+ // ───────────── type-triggered autocomplete (3.1) ─────────────
3988
+ // A free-text `type` into a Google-Places-style address field or a
3989
+ // react-select/cmdk/Radix combobox can open the same kind of suggestion
3990
+ // popup `select`'s selectFromCombobox() already knows how to drive — the
3991
+ // difference is the host issued `type`, not an explicit `select`, so
3992
+ // nothing today detects or commits the popup. These three methods reuse
3993
+ // the exact open-detect/click machinery selectFromCombobox() uses; the
3994
+ // match-or-stop decision of WHICH option (if any) to click lives in
3995
+ // provision-session.ts (matchAutocompleteSuggestions), kept a pure
3996
+ // function there so it's unit-testable without a browser.
3997
+ /**
3998
+ * Snapshot popups that already exist BEFORE typing — the suggestion popup
3999
+ * can open mid-keystroke, so this must run before type(), not after.
4000
+ */
4001
+ async markPreexistingTypeSuggestionPopups() {
4002
+ await this.markComboboxPreexistingElements();
4003
+ }
4004
+ /**
4005
+ * After typing into `selector`, detect whether a suggestion popup opened
4006
+ * as a side effect and return its option texts in DOM order. Empty when
4007
+ * no popup opened — `type` behaved as an ordinary text field and the
4008
+ * caller should no-op. Google-Places-style pickers debounce (~200ms)
4009
+ * plus a network round-trip before rendering suggestions, so a single
4010
+ * synchronous check right after the last keystroke commonly sees
4011
+ * nothing — poll on a bounded budget (mirroring settleAfterStateChange's
4012
+ * shape), returning as soon as options appear so an already-open popup
4013
+ * pays no extra latency.
4014
+ */
4015
+ async detectTypeSuggestionPopup(selector) {
4016
+ if (!this.page)
4017
+ throw new Error("Browser not started");
4018
+ for (let attempt = 0; attempt < 6; attempt += 1) {
4019
+ if (attempt > 0)
4020
+ await this.sleep(300);
4021
+ await this.refreshComboboxMarkers(selector);
4022
+ const options = this.page.locator("[data-ts-select-option-tier]");
4023
+ const count = await options.count();
4024
+ if (count === 0)
4025
+ continue;
4026
+ const texts = [];
4027
+ for (let i = 0; i < count; i += 1) {
4028
+ texts.push((await options.nth(i).innerText()).replace(/\s+/g, " ").trim());
4029
+ }
4030
+ return texts;
4031
+ }
4032
+ return [];
4033
+ }
4034
+ /** Click the option at `index` (as indexed by detectTypeSuggestionPopup). */
4035
+ async commitTypeSuggestion(index) {
4036
+ if (!this.page)
4037
+ throw new Error("Browser not started");
4038
+ const options = this.page.locator("[data-ts-select-option-tier]");
4039
+ await this.clickComboboxOption(options.nth(index));
4040
+ }
4041
+ /**
4042
+ * Clean up after a type-triggered autocomplete interaction, regardless of
4043
+ * outcome (committed, ambiguous/zero-match stop, or a failed commit).
4044
+ * When `dismissWithEscape` is true — the caller determined a detected
4045
+ * popup is plausibly still open (an ambiguous/zero-match stop where no
4046
+ * option was ever clicked, or a commit whose confirmation failed) —
4047
+ * presses Escape to dismiss it BEFORE clearing our own tracking markers:
4048
+ * some widgets (Google Places classic's `.pac-container` in particular)
4049
+ * never fully unmount, just toggle visibility, so a popup left open would
4050
+ * otherwise be captured as "preexisting" the next time
4051
+ * markPreexistingTypeSuggestionPopups snapshots the page (it snapshots by
4052
+ * current visibility, not by who opened it), silently disabling detection
4053
+ * on a host's very next retry. Escape must NOT fire when no popup was
4054
+ * ever detected, nor after a confirmed successful commit (the widget
4055
+ * already closed its popup on selection): it commonly bubbles to close an
4056
+ * enclosing modal/dialog too, so firing it with nothing left to dismiss
4057
+ * risks closing a dialog the typed-into field happens to live in (a
4058
+ * cmdk-in-Radix-dialog combobox, a cart-drawer quantity field, a login
4059
+ * modal's email field, an address-edit modal). Marker clearing stays
4060
+ * unconditional — it only removes our own tracking attributes, never
4061
+ * touches page behavior.
4062
+ */
4063
+ async discardTypeSuggestionPopup(dismissWithEscape) {
4064
+ if (dismissWithEscape)
4065
+ await this.pressKey("Escape");
4066
+ await this.clearComboboxMarkers();
4067
+ }
4068
+ /**
4069
+ * After commitTypeSuggestion, POSITIVELY confirm the picked option's value
4070
+ * actually committed. A same-selector `.value` change is one signal, but
4071
+ * react-select/cmdk-style widgets clear their search input on selection
4072
+ * and render the committed choice in a separate nearby element instead —
4073
+ * checking only the original selector's `.value` would false-fail on
4074
+ * exactly the widgets this feature targets. Checks, in order: the field's
4075
+ * own live value; a native `<select>`'s selected-option label; a nearby
4076
+ * (within two DOM ancestor hops of the field, never page-wide) element
4077
+ * whose own text or aria-label exactly equals the picked option's text.
4078
+ * Returns false — never true — when nothing positively confirms it; the
4079
+ * caller must treat false as a miss (stop), never as a silent success.
4080
+ */
4081
+ async confirmAutocompleteCommitted(fieldSelector, pickedOptionText) {
4082
+ if (!this.page)
4083
+ throw new Error("Browser not started");
4084
+ try {
4085
+ return await this.page
4086
+ .locator(fieldSelector)
4087
+ .first()
4088
+ .evaluate((field, wantedRaw) => {
4089
+ const normalize = (s) => (s ?? "").toLowerCase().replace(/\s+/g, " ").trim();
4090
+ const wanted = normalize(wantedRaw);
4091
+ if (wanted.length === 0)
4092
+ return false;
4093
+ const ownValue = field instanceof HTMLInputElement || field instanceof HTMLTextAreaElement
4094
+ ? field.value
4095
+ : "";
4096
+ if (normalize(ownValue) === wanted)
4097
+ return true;
4098
+ if (field instanceof HTMLSelectElement) {
4099
+ const opt = field.options[field.selectedIndex];
4100
+ if (opt !== undefined && normalize(opt.textContent) === wanted)
4101
+ return true;
4102
+ }
4103
+ // Bounded to the field's immediate wrapper (walk up at most two
4104
+ // ancestors) so a coincidental text match elsewhere on the page —
4105
+ // or elsewhere in a large `<form>` — can't produce a false
4106
+ // positive. React-select/cmdk render the committed choice as a
4107
+ // sibling of the (now-cleared) search input under a shared
4108
+ // control wrapper, well within this range.
4109
+ //
4110
+ // This runs BEFORE discardTypeSuggestionPopup (Escape is in the
4111
+ // caller's finally), so a suggestion menu that never really
4112
+ // committed can still be open here — and menus commonly render
4113
+ // within two ancestor hops of the input too. Without excluding
4114
+ // it, the picked OPTION's own (still-visible, un-really-clicked)
4115
+ // element trivially satisfies the text-equality check every
4116
+ // time, defeating the "positively confirm or stop" guarantee for
4117
+ // exactly the failure this exists to catch. Exclude anything
4118
+ // that IS, is INSIDE, or CONTAINS our own tracked popup/option
4119
+ // markers — the last case matters because `.textContent`
4120
+ // aggregates every descendant's text, so a plain wrapper div that
4121
+ // merely contains the still-open popup reads as if it displayed
4122
+ // the picked text itself.
4123
+ const TRACKED_POPUP_SELECTOR = "[data-ts-select-popup],[data-ts-select-option-tier]";
4124
+ const inTrackedPopup = (el) => el.matches(TRACKED_POPUP_SELECTOR) ||
4125
+ el.closest("[data-ts-select-popup]") !== null ||
4126
+ el.querySelector(TRACKED_POPUP_SELECTOR) !== null;
4127
+ let scope = field.parentElement;
4128
+ for (let hop = 0; hop < 2 && scope !== null; hop += 1) {
4129
+ const found = Array.from(scope.querySelectorAll("*")).some((el) => {
4130
+ if (el === field || inTrackedPopup(el))
4131
+ return false;
4132
+ if (normalize(el.textContent) === wanted)
4133
+ return true;
4134
+ const ariaLabel = el.getAttribute("aria-label");
4135
+ return ariaLabel !== null && normalize(ariaLabel) === wanted;
4136
+ });
4137
+ if (found)
4138
+ return true;
4139
+ scope = scope.parentElement;
4140
+ }
4141
+ return false;
4142
+ }, pickedOptionText);
4143
+ }
4144
+ catch {
4145
+ return false;
4146
+ }
4147
+ }
3934
4148
  // ───────────── humanization internals ─────────────
3935
4149
  // Click that mimics a real user: locate element, bezier-path the
3936
4150
  // mouse to it, hover briefly, then click. The mouse position is
@@ -5258,7 +5472,55 @@ export class BrowserController {
5258
5472
  async extractVisibleText() {
5259
5473
  if (!this.page)
5260
5474
  throw new Error("Browser not started");
5261
- return await this.page.evaluate(() => document.body?.innerText ?? "");
5475
+ return await this.page.evaluate(() => {
5476
+ const body = document.body;
5477
+ if (!body)
5478
+ return "";
5479
+ // innerText excludes display:none/visibility:hidden but NOT opacity:0 —
5480
+ // an element with opacity:0 keeps its layout box, so innerText still
5481
+ // walks it. Hidden-native-<select> country-code widgets (e.g.
5482
+ // react-phone-number-input) commonly style the real <select>
5483
+ // opacity:0 (rather than display:none) so a native mobile picker still
5484
+ // opens on tap; with every <option> in the box, that dumps hundreds of
5485
+ // country names into the page text, flooding/truncating what a sighted
5486
+ // user never sees (and what really matters, like an error banner past
5487
+ // the truncation cutoff). A detached clone won't do: innerText on an
5488
+ // unrendered element degrades to textContent semantics, re-including
5489
+ // the display:none/script text innerText exists to exclude. Instead,
5490
+ // temporarily force display:none on opacity:0 elements in the LIVE
5491
+ // body, read innerText, and restore each element's style attribute
5492
+ // byte-identically (removing it when it was absent) — all inside
5493
+ // this one synchronous evaluate, so the change is never painted.
5494
+ const hidden = [];
5495
+ let text = "";
5496
+ try {
5497
+ for (const el of Array.from(body.querySelectorAll("*"))) {
5498
+ if (!(el instanceof HTMLElement || el instanceof SVGElement))
5499
+ continue;
5500
+ if (window.getComputedStyle(el).opacity === "0") {
5501
+ hidden.push({ el, style: el.getAttribute("style") });
5502
+ el.style.setProperty("display", "none", "important");
5503
+ }
5504
+ }
5505
+ text = body.innerText ?? "";
5506
+ }
5507
+ finally {
5508
+ for (const { el, style } of hidden) {
5509
+ if (style === null) {
5510
+ // Plain removeAttribute here leaves an empty style="" behind:
5511
+ // Blink lazily re-syncs the dirty CSSOM declaration back into the
5512
+ // attribute after the innerText read. Clear the declaration first.
5513
+ el.style.removeProperty("display");
5514
+ if (el.getAttribute("style") === "")
5515
+ el.removeAttribute("style");
5516
+ }
5517
+ else {
5518
+ el.setAttribute("style", style);
5519
+ }
5520
+ }
5521
+ }
5522
+ return text;
5523
+ });
5262
5524
  }
5263
5525
  async readCheckoutSummary(fallbackCurrency) {
5264
5526
  if (!this.page)
@@ -5289,6 +5551,127 @@ export class BrowserController {
5289
5551
  ...amount,
5290
5552
  };
5291
5553
  }
5554
+ /**
5555
+ * Read a settled checkout-review amount. Checkout pages can retain an
5556
+ * earlier subtotal while asynchronously replacing the final labeled total;
5557
+ * the harness calls this only after it has proved a shipping method is
5558
+ * present, then requires this value to remain stable across two reads.
5559
+ * Payment continues to use readCheckoutSummary's original first-read
5560
+ * contract; this is a review-only, pre-payment reader.
5561
+ */
5562
+ async readCheckoutReviewSummary(fallbackCurrency) {
5563
+ if (!this.page)
5564
+ throw new Error("Browser not started");
5565
+ const page = this.page;
5566
+ const identity = await page.evaluate(() => ({
5567
+ title: document.title,
5568
+ siteName: document.querySelector('meta[property="og:site_name"]')?.content ??
5569
+ document.querySelector('[itemprop="merchant"]')?.textContent ??
5570
+ "",
5571
+ }));
5572
+ const texts = await Promise.all(page
5573
+ .frames()
5574
+ .map(async (frame) => await frame.evaluate(() => document.body?.innerText ?? "").catch(() => "")));
5575
+ const amount = parseCheckoutAmounts(texts, fallbackCurrency).at(-1);
5576
+ if (amount === undefined)
5577
+ throw new Error("payment_checkout_total_not_found");
5578
+ return {
5579
+ merchant: merchantFromPage(identity.title, identity.siteName, page.url()),
5580
+ checkout_origin: new URL(page.url()).origin,
5581
+ ...amount,
5582
+ };
5583
+ }
5584
+ async readCheckoutReviewLineItems() {
5585
+ if (!this.page)
5586
+ throw new Error("Browser not started");
5587
+ return await this.page.evaluate(() => {
5588
+ const normalize = (value) => value.replace(/\s+/g, " ").trim();
5589
+ const visible = (element) => {
5590
+ if (!(element instanceof HTMLElement) || element.getClientRects().length === 0)
5591
+ return false;
5592
+ const style = getComputedStyle(element);
5593
+ return style.display !== "none" && style.visibility !== "hidden";
5594
+ };
5595
+ const quantityIn = (container) => {
5596
+ const field = Array.from(container.querySelectorAll('input[name*="quantity" i], input[aria-label*="quantity" i], select[name*="quantity" i]')).find(visible);
5597
+ const fieldValue = field?.value === undefined ? Number.NaN : Number(field.value);
5598
+ if (Number.isInteger(fieldValue) && fieldValue > 0)
5599
+ return fieldValue;
5600
+ const text = normalize(container.textContent ?? "");
5601
+ const labeled = /\bquantity\s*:?[\s\n]*(\d+)\b/i.exec(text)?.[1];
5602
+ const parsed = Number(labeled);
5603
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined;
5604
+ };
5605
+ const titleFrom = (container) => {
5606
+ const candidates = [
5607
+ ...Array.from(container.querySelectorAll('a[href*="/products/"], [data-testid*="title" i], [class*="title" i], h1, h2, h3, h4, td, [role="cell"]'), (element) => normalize(element.innerText)),
5608
+ ...Array.from(container.querySelectorAll("img[alt]"), (image) => normalize(image.alt)),
5609
+ ];
5610
+ return candidates.find((candidate) => candidate.length > 0 &&
5611
+ candidate.length <= 240 &&
5612
+ !/^product(?: image| information)?$/i.test(candidate) &&
5613
+ !/^quantity\b/i.test(candidate) &&
5614
+ !/^\$|\$\s*\d/.test(candidate));
5615
+ };
5616
+ const rows = Array.from(document.querySelectorAll('tr, [role="row"], [data-testid*="line-item" i], [data-testid*="product" i], [class*="line-item" i], [class*="product" i]')).filter(visible);
5617
+ const observed = rows.flatMap((row) => {
5618
+ const quantity = quantityIn(row);
5619
+ const title = titleFrom(row);
5620
+ return quantity === undefined || title === undefined
5621
+ ? []
5622
+ : [{ title, quantity, size: normalize(row.textContent ?? "").length }];
5623
+ });
5624
+ const byTitle = new Map();
5625
+ for (const item of observed) {
5626
+ const key = item.title.toLowerCase();
5627
+ const previous = byTitle.get(key);
5628
+ if (previous === undefined || item.size < previous.size)
5629
+ byTitle.set(key, item);
5630
+ }
5631
+ return [...byTitle.values()].map(({ title, quantity }) => ({ title, quantity }));
5632
+ });
5633
+ }
5634
+ async readSettledCheckoutReviewSummary(fallbackCurrency, timeoutMs = 12_000) {
5635
+ if (!this.page)
5636
+ throw new Error("Browser not started");
5637
+ const deadline = Date.now() + timeoutMs;
5638
+ let previous;
5639
+ while (Date.now() < deadline) {
5640
+ const shippingReady = await this.page
5641
+ .evaluate(() => {
5642
+ const labels = Array.from(document.querySelectorAll('input[type="radio"]'))
5643
+ .flatMap((input) => [
5644
+ ...(input.labels === null
5645
+ ? []
5646
+ : Array.from(input.labels, (label) => label.innerText)),
5647
+ input.getAttribute("aria-label") ?? "",
5648
+ input.closest("label")?.textContent ?? "",
5649
+ ])
5650
+ .map((label) => label.replace(/\s+/g, " ").trim())
5651
+ .filter((label) => label.length > 0 && !/loading/i.test(label));
5652
+ return (/\bdelivery\b|\bshipping\b/i.test(document.body?.innerText ?? "") &&
5653
+ labels.some((label) => /\b(?:standard|express|shipping|delivery|pickup)\b/i.test(label)));
5654
+ })
5655
+ .catch(() => false);
5656
+ if (shippingReady) {
5657
+ const current = await Promise.all([
5658
+ this.readCheckoutReviewSummary(fallbackCurrency),
5659
+ this.readCheckoutReviewLineItems(),
5660
+ ])
5661
+ .then(([summary, line_items]) => ({ ...summary, line_items }))
5662
+ .catch(() => undefined);
5663
+ if (current !== undefined &&
5664
+ current.line_items.length > 0 &&
5665
+ previous !== undefined &&
5666
+ JSON.stringify(current) === JSON.stringify(previous)) {
5667
+ return current;
5668
+ }
5669
+ previous = current;
5670
+ }
5671
+ await this.page.waitForTimeout(250);
5672
+ }
5673
+ return undefined;
5674
+ }
5292
5675
  async isPayPalHostedCheckout() {
5293
5676
  if (!this.page)
5294
5677
  throw new Error("Browser not started");
@@ -7557,6 +7940,11 @@ export class BrowserController {
7557
7940
  const lm = el.closest("header,main,footer,nav,aside,article,section");
7558
7941
  return lm !== null ? lm.tagName.toLowerCase() : null;
7559
7942
  })(),
7943
+ // Locale-stable role signals for money-path fill guards. Prefer
7944
+ // autocomplete over visible labels (labels flip under i18n).
7945
+ autocomplete: (el.getAttribute("autocomplete") ?? "").trim() || null,
7946
+ dataRole: (el.getAttribute("data-field-role") ?? el.getAttribute("data-role") ?? "").trim() ||
7947
+ null,
7560
7948
  value: el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement
7561
7949
  ? el.value
7562
7950
  : el instanceof HTMLSelectElement
@@ -7612,6 +8000,29 @@ export class BrowserController {
7612
8000
  cardRadioGroup: groups[i] ?? null,
7613
8001
  }));
7614
8002
  }
8003
+ // replay-per-leg-signature — the checkout-leg shape signature (see
8004
+ // checkoutFieldSetSignature in @trusty-squire/recipe-schema) is computed
8005
+ // from a page's FULL field-name set, deliberately including `type=hidden`
8006
+ // fields — unlike extractInteractiveElements above (which deliberately
8007
+ // skips hidden/password inputs, since those aren't things a planner can
8008
+ // act on), a checkout platform's own hidden session/GraphQL-serialized
8009
+ // fields are exactly the stable, platform-authored signal the signature
8010
+ // depends on. Reads every input/select/textarea's `name` (falling back to
8011
+ // `id`) with a single flat query — no visibility/shadow-DOM handling,
8012
+ // matching the method proven in the field-name-set discriminator report.
8013
+ async extractCheckoutFieldNames() {
8014
+ if (!this.page)
8015
+ throw new Error("Browser not started");
8016
+ return await this.page.evaluate(() => {
8017
+ const names = [];
8018
+ document.querySelectorAll("input,select,textarea").forEach((el) => {
8019
+ const name = el.getAttribute("name") ?? el.getAttribute("id") ?? "";
8020
+ if (name.length > 0)
8021
+ names.push(name);
8022
+ });
8023
+ return names;
8024
+ });
8025
+ }
7615
8026
  // Resolve a selector against the live page for the verify step
7616
8027
  // (F3 T5). Returns the match count plus the first match's
7617
8028
  // tag/id/name so the caller can confirm a still-resolving selector
@@ -8537,6 +8948,14 @@ export class BrowserController {
8537
8948
  });
8538
8949
  }
8539
8950
  async close() {
8951
+ if (this.harnessAttachedPage) {
8952
+ this.page = null;
8953
+ this.primaryPage = null;
8954
+ this.oauthProductPage = null;
8955
+ this.oauthNetLog = [];
8956
+ this.context = null;
8957
+ return;
8958
+ }
8540
8959
  // Each step is best-effort and independent: a throw closing the page
8541
8960
  // or context must NOT skip the Xvfb teardown below, or the virtual
8542
8961
  // display leaks (orphaned Xvfb procs pile up over a long-lived MCP