@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.
- package/README.md +13 -5
- package/dist/bot/browser.d.ts +96 -1
- package/dist/bot/browser.d.ts.map +1 -1
- package/dist/bot/browser.js +420 -1
- package/dist/bot/browser.js.map +1 -1
- package/dist/bot/operator-recipe.d.ts +27 -975
- package/dist/bot/operator-recipe.d.ts.map +1 -1
- package/dist/bot/operator-recipe.js +88 -237
- package/dist/bot/operator-recipe.js.map +1 -1
- package/dist/bot/pay-operator.d.ts +2 -0
- package/dist/bot/pay-operator.d.ts.map +1 -1
- package/dist/bot/pay-operator.js +9 -5
- package/dist/bot/pay-operator.js.map +1 -1
- package/dist/bot/provision-session.d.ts +44 -2
- package/dist/bot/provision-session.d.ts.map +1 -1
- package/dist/bot/provision-session.js +383 -4
- package/dist/bot/provision-session.js.map +1 -1
- package/dist/skill-registry-client.d.ts +57 -0
- package/dist/skill-registry-client.d.ts.map +1 -1
- package/dist/skill-registry-client.js +156 -7
- package/dist/skill-registry-client.js.map +1 -1
- package/dist/tools/provision-drive.d.ts +73 -26
- package/dist/tools/provision-drive.d.ts.map +1 -1
- package/dist/tools/provision-drive.js +224 -10
- package/dist/tools/provision-drive.js.map +1 -1
- package/package.json +3 -1
- package/dist/eval/replay-harness/corpus.d.ts +0 -7
- package/dist/eval/replay-harness/corpus.d.ts.map +0 -1
- package/dist/eval/replay-harness/corpus.js +0 -183
- package/dist/eval/replay-harness/corpus.js.map +0 -1
- package/dist/eval/replay-harness/har-mutate.d.ts +0 -36
- package/dist/eval/replay-harness/har-mutate.d.ts.map +0 -1
- package/dist/eval/replay-harness/har-mutate.js +0 -119
- package/dist/eval/replay-harness/har-mutate.js.map +0 -1
- package/dist/eval/replay-harness/har-substrate.d.ts +0 -16
- package/dist/eval/replay-harness/har-substrate.d.ts.map +0 -1
- package/dist/eval/replay-harness/har-substrate.js +0 -99
- package/dist/eval/replay-harness/har-substrate.js.map +0 -1
- package/dist/eval/replay-harness/index.d.ts +0 -8
- package/dist/eval/replay-harness/index.d.ts.map +0 -1
- package/dist/eval/replay-harness/index.js +0 -8
- package/dist/eval/replay-harness/index.js.map +0 -1
- package/dist/eval/replay-harness/metrics.d.ts +0 -9
- package/dist/eval/replay-harness/metrics.d.ts.map +0 -1
- package/dist/eval/replay-harness/metrics.js +0 -117
- package/dist/eval/replay-harness/metrics.js.map +0 -1
- package/dist/eval/replay-harness/reporter.d.ts +0 -4
- package/dist/eval/replay-harness/reporter.d.ts.map +0 -1
- package/dist/eval/replay-harness/reporter.js +0 -62
- package/dist/eval/replay-harness/reporter.js.map +0 -1
- package/dist/eval/replay-harness/runner.d.ts +0 -36
- package/dist/eval/replay-harness/runner.d.ts.map +0 -1
- package/dist/eval/replay-harness/runner.js +0 -167
- package/dist/eval/replay-harness/runner.js.map +0 -1
- package/dist/eval/replay-harness/types.d.ts +0 -135
- package/dist/eval/replay-harness/types.d.ts.map +0 -1
- package/dist/eval/replay-harness/types.js +0 -2
- package/dist/eval/replay-harness/types.js.map +0 -1
package/dist/bot/browser.js
CHANGED
|
@@ -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(() =>
|
|
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
|