@trusty-squire/mcp 1.0.50 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -109,6 +109,90 @@ function getChromium() {
109
109
  }
110
110
  return cachedChromium;
111
111
  }
112
+ const CURRENCY_SYMBOLS = {
113
+ $: "USD",
114
+ "€": "EUR",
115
+ "£": "GBP",
116
+ "¥": "JPY",
117
+ };
118
+ function currencyMinorDigits(currency) {
119
+ return new Intl.NumberFormat(undefined, {
120
+ style: "currency",
121
+ currency,
122
+ }).resolvedOptions().maximumFractionDigits;
123
+ }
124
+ function parseDisplayedNumber(raw, minorDigits) {
125
+ const value = raw.replace(/\s/g, "");
126
+ const comma = value.lastIndexOf(",");
127
+ const dot = value.lastIndexOf(".");
128
+ let normalized = value;
129
+ if (comma >= 0 && dot >= 0) {
130
+ const decimalIndex = Math.max(comma, dot);
131
+ const fractionLength = value.length - decimalIndex - 1;
132
+ if (minorDigits > 0 && fractionLength > 0 && fractionLength <= minorDigits) {
133
+ const integer = value.slice(0, decimalIndex).replace(/[.,]/g, "");
134
+ normalized = `${integer}.${value.slice(decimalIndex + 1)}`;
135
+ }
136
+ else {
137
+ normalized = value.replace(/[.,]/g, "");
138
+ }
139
+ }
140
+ else if (comma >= 0) {
141
+ const commaCount = (value.match(/,/g) ?? []).length;
142
+ const fractionLength = value.length - comma - 1;
143
+ normalized =
144
+ commaCount === 1 && minorDigits > 0 && fractionLength > 0 && fractionLength <= minorDigits
145
+ ? value.replace(",", ".")
146
+ : value.replaceAll(",", "");
147
+ }
148
+ else if ((value.match(/\./g) ?? []).length > 1) {
149
+ normalized = value.replaceAll(".", "");
150
+ }
151
+ else if (dot >= 0) {
152
+ const fractionLength = value.length - dot - 1;
153
+ normalized =
154
+ minorDigits > 0 && fractionLength > 0 && fractionLength <= minorDigits
155
+ ? value
156
+ : value.replace(".", "");
157
+ }
158
+ const parsed = Number(normalized);
159
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
160
+ }
161
+ export function parseCheckoutAmount(texts, fallbackCurrency) {
162
+ const totalPattern = /\b(?:order\s+total|grand\s+total|total\s+due|amount\s+due|total)\b\s*:?\s*(?:(USD|EUR|GBP|CAD|AUD|JPY|NZD|CHF|SEK|NOK|DKK)\s*)?([$€£¥])?\s*([0-9][0-9.,]*)(?:\s*(USD|EUR|GBP|CAD|AUD|JPY|NZD|CHF|SEK|NOK|DKK))?/gi;
163
+ for (const text of texts) {
164
+ totalPattern.lastIndex = 0;
165
+ for (const match of text.matchAll(totalPattern)) {
166
+ const currency = (match[1] ??
167
+ match[4] ??
168
+ CURRENCY_SYMBOLS[match[2] ?? ""] ??
169
+ fallbackCurrency)?.toUpperCase();
170
+ if (currency === undefined || !/^[A-Z]{3}$/.test(currency))
171
+ continue;
172
+ const minorDigits = currencyMinorDigits(currency);
173
+ const amount = parseDisplayedNumber(match[3] ?? "", minorDigits);
174
+ if (amount === null)
175
+ continue;
176
+ const scale = 10 ** minorDigits;
177
+ const minor = Math.round(amount * scale);
178
+ if (Math.abs(amount * scale - minor) > 1e-6)
179
+ continue;
180
+ return { amount_cents: minor, currency };
181
+ }
182
+ }
183
+ return null;
184
+ }
185
+ function merchantFromPage(title, siteName, url) {
186
+ if (siteName.trim().length > 0)
187
+ return siteName.trim().slice(0, 256);
188
+ const titlePart = title
189
+ .split(/\s+[|—–-]\s+/)
190
+ .find((part) => !/\b(checkout|payment|cart|order)\b/i.test(part));
191
+ if (titlePart !== undefined && titlePart.trim().length > 0) {
192
+ return titlePart.trim().slice(0, 256);
193
+ }
194
+ return new URL(url).hostname.replace(/^www\./, "").slice(0, 256);
195
+ }
112
196
  const HCAPTCHA_UUID_RE = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}";
113
197
  export function extractHcaptchaSitekeyFromHtml(html) {
114
198
  if (!/hcaptcha\.com|h-captcha|hcaptcha/i.test(html))
@@ -207,10 +291,7 @@ function isCaptchaVariant(v) {
207
291
  function pngDimensions(buf) {
208
292
  if (buf.length < 24)
209
293
  return null;
210
- if (buf[0] !== 0x89 ||
211
- buf[1] !== 0x50 ||
212
- buf[2] !== 0x4e ||
213
- buf[3] !== 0x47) {
294
+ if (buf[0] !== 0x89 || buf[1] !== 0x50 || buf[2] !== 0x4e || buf[3] !== 0x47) {
214
295
  return null;
215
296
  }
216
297
  return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
@@ -226,12 +307,7 @@ function pngDimensions(buf) {
226
307
  // hardest to fingerprint as automation. Stable Chrome > Edge >
227
308
  // Beta/Canary > Brave. Brave isn't a Playwright channel but its
228
309
  // binary path is well-known; we resolve it explicitly below.
229
- const PREFERRED_CHANNELS = [
230
- "chrome",
231
- "msedge",
232
- "chrome-beta",
233
- "chrome-canary",
234
- ];
310
+ const PREFERRED_CHANNELS = ["chrome", "msedge", "chrome-beta", "chrome-canary"];
235
311
  // Per-channel binary search paths. Playwright's `executablePath()` is
236
312
  // argumentless (returns the bundled Chromium path), so we can't ask it
237
313
  // "is Chrome installed?" — we have to look ourselves. These are the
@@ -784,9 +860,7 @@ export class BrowserController {
784
860
  this.humanize = opts.humanize ?? true;
785
861
  this.profileDir = opts.profileDir ?? CHROME_PROFILE_DIR;
786
862
  this.proxyOverride =
787
- opts.proxyUrl !== undefined && opts.proxyUrl.trim().length > 0
788
- ? opts.proxyUrl.trim()
789
- : null;
863
+ opts.proxyUrl !== undefined && opts.proxyUrl.trim().length > 0 ? opts.proxyUrl.trim() : null;
790
864
  }
791
865
  // Per-launch egress override (verify-fleet identities each get their own IP).
792
866
  // null → use the env-global proxy. See resolveProxy().
@@ -870,7 +944,10 @@ export class BrowserController {
870
944
  ...(params.headless ? ["--headless=new"] : []),
871
945
  "about:blank",
872
946
  ];
873
- const child = spawn(params.binary, argv, { env: params.env, stdio: ["ignore", "ignore", "pipe"] });
947
+ const child = spawn(params.binary, argv, {
948
+ env: params.env,
949
+ stdio: ["ignore", "ignore", "pipe"],
950
+ });
874
951
  this.childChrome = child;
875
952
  registerSelfManagedChrome(child);
876
953
  let chromeStderr = "";
@@ -943,17 +1020,35 @@ export class BrowserController {
943
1020
  return;
944
1021
  const BLOCK_TYPES = new Set(["image", "media", "font"]);
945
1022
  const BLOCK_HOSTS = [
946
- "google-analytics.com", "googletagmanager.com", "analytics.google.com",
947
- "doubleclick.net", "static.hotjar.com", "script.hotjar.com",
948
- "segment.com", "segment.io", "cdn.segment.com", "fullstory.com",
949
- "mixpanel.com", "bugsnag.com", "intercom.io", "intercomcdn.com",
950
- "widget.intercom.io", "connect.facebook.net", "analytics.tiktok.com",
951
- "clarity.ms", "cdn.heapanalytics.com", "wistia.com",
1023
+ "google-analytics.com",
1024
+ "googletagmanager.com",
1025
+ "analytics.google.com",
1026
+ "doubleclick.net",
1027
+ "static.hotjar.com",
1028
+ "script.hotjar.com",
1029
+ "segment.com",
1030
+ "segment.io",
1031
+ "cdn.segment.com",
1032
+ "fullstory.com",
1033
+ "mixpanel.com",
1034
+ "bugsnag.com",
1035
+ "intercom.io",
1036
+ "intercomcdn.com",
1037
+ "widget.intercom.io",
1038
+ "connect.facebook.net",
1039
+ "analytics.tiktok.com",
1040
+ "clarity.ms",
1041
+ "cdn.heapanalytics.com",
1042
+ "wistia.com",
952
1043
  ];
953
1044
  // NEVER block — these break signup (captcha/challenge widgets + payment SDK).
954
1045
  const ALWAYS_ALLOW = [
955
- "challenges.cloudflare.com", "turnstile", "hcaptcha.com",
956
- "newassets.hcaptcha.com", "recaptcha", "gstatic.com/recaptcha",
1046
+ "challenges.cloudflare.com",
1047
+ "turnstile",
1048
+ "hcaptcha.com",
1049
+ "newassets.hcaptcha.com",
1050
+ "recaptcha",
1051
+ "gstatic.com/recaptcha",
957
1052
  "js.stripe.com",
958
1053
  ];
959
1054
  await ctx.route("**/*", async (route) => {
@@ -1393,7 +1488,6 @@ export class BrowserController {
1393
1488
  void (async () => {
1394
1489
  if (trace) {
1395
1490
  const before = await frame.evaluate(RENDERER_PROBE).catch(() => "eval-fail");
1396
- // eslint-disable-next-line no-console
1397
1491
  console.error(`[captcha-fp] ${cfHost} renderer BEFORE spoof: ${before}`);
1398
1492
  }
1399
1493
  // Retry until the spoof STICKS. The first framenavigated commonly
@@ -1412,7 +1506,6 @@ export class BrowserController {
1412
1506
  await new Promise((res) => setTimeout(res, 150));
1413
1507
  }
1414
1508
  if (trace) {
1415
- // eslint-disable-next-line no-console
1416
1509
  console.error(`[captcha-fp] ${cfHost} renderer AFTER spoof: ${landed ? "Intel (landed)" : "FAILED to land in budget"}`);
1417
1510
  }
1418
1511
  })();
@@ -1439,8 +1532,7 @@ export class BrowserController {
1439
1532
  /api\.hcaptcha\.com\/(?:checksiteconfig|getcaptcha|checkcaptcha)/.test(url)) {
1440
1533
  try {
1441
1534
  const body = await resp.text();
1442
- bodyPreview =
1443
- body.length > 400 ? body.slice(0, 400) + "…" : body;
1535
+ bodyPreview = body.length > 400 ? body.slice(0, 400) + "…" : body;
1444
1536
  }
1445
1537
  catch {
1446
1538
  // body may be evicted; ignore
@@ -1590,8 +1682,7 @@ export class BrowserController {
1590
1682
  // not fire. MEASURED 2026-07-01 (Loops "Login link": the results list has no
1591
1683
  // /api/auth/callback href; opening the row reveals it).
1592
1684
  const els = await this.extractInteractiveElements();
1593
- const row = els.find((e) => e.role === "link" &&
1594
- (e.visibleText ?? e.ariaLabel ?? e.labelText ?? "").trim().length > 25);
1685
+ const row = els.find((e) => e.role === "link" && (e.visibleText ?? e.ariaLabel ?? e.labelText ?? "").trim().length > 25);
1595
1686
  if (row === undefined)
1596
1687
  return false;
1597
1688
  await this.click(row.selector).catch(() => { });
@@ -1626,7 +1717,9 @@ export class BrowserController {
1626
1717
  try {
1627
1718
  const left = new URL(a);
1628
1719
  const right = new URL(b);
1629
- return left.origin === right.origin && left.pathname === right.pathname && left.search === right.search;
1720
+ return (left.origin === right.origin &&
1721
+ left.pathname === right.pathname &&
1722
+ left.search === right.search);
1630
1723
  }
1631
1724
  catch {
1632
1725
  return false;
@@ -1678,7 +1771,9 @@ export class BrowserController {
1678
1771
  if (landedAuthGateForTarget(this.page.url(), url))
1679
1772
  break;
1680
1773
  await this.page
1681
- .waitForURL((landed) => sameOriginPathAndSearch(landed.toString(), url), { timeout: 5000 })
1774
+ .waitForURL((landed) => sameOriginPathAndSearch(landed.toString(), url), {
1775
+ timeout: 5000,
1776
+ })
1682
1777
  .then(() => undefined)
1683
1778
  .catch(() => undefined);
1684
1779
  if (sameOriginPathAndSearch(this.page.url(), url))
@@ -2052,7 +2147,7 @@ export class BrowserController {
2052
2147
  // — the trigger updates + Next un-gates — but only when we target the
2053
2148
  // option element, not its child span (which a raw coordinate click drops).
2054
2149
  const optEl = el.closest('[role="option"],[role="menuitem"],[role="menuitemradio"],[cmdk-item]');
2055
- const optRole = optEl !== null ? optEl.getAttribute("role") ?? "option" : "";
2150
+ const optRole = optEl !== null ? (optEl.getAttribute("role") ?? "option") : "";
2056
2151
  const optText = optEl !== null ? (optEl.textContent ?? "").trim().slice(0, 80) : "";
2057
2152
  return {
2058
2153
  inputKind,
@@ -2076,7 +2171,9 @@ export class BrowserController {
2076
2171
  // NOT the anti-bot-scored gate.
2077
2172
  const optRole = probe.role === "option" || probe.role === "menuitem" || probe.role === "menuitemradio"
2078
2173
  ? probe.role
2079
- : probe.optRole === "option" || probe.optRole === "menuitem" || probe.optRole === "menuitemradio"
2174
+ : probe.optRole === "option" ||
2175
+ probe.optRole === "menuitem" ||
2176
+ probe.optRole === "menuitemradio"
2080
2177
  ? probe.optRole
2081
2178
  : "";
2082
2179
  const optName = probe.role !== "" ? probe.text : probe.optText;
@@ -2197,9 +2294,7 @@ export class BrowserController {
2197
2294
  // Escape regex metacharacters in the user-supplied label text so
2198
2295
  // a literal "(2FA)" or "." doesn't get interpreted as a pattern.
2199
2296
  const escaped = text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2200
- const locator = this.page
2201
- .getByText(new RegExp(escaped, "i"))
2202
- .first();
2297
+ const locator = this.page.getByText(new RegExp(escaped, "i")).first();
2203
2298
  await locator.waitFor({ state: "visible", timeout: timeoutMs });
2204
2299
  if (this.humanize) {
2205
2300
  await this.humanClickLocator(locator);
@@ -2322,10 +2417,16 @@ export class BrowserController {
2322
2417
  async ensureChecked(selector) {
2323
2418
  if (!this.page)
2324
2419
  return false;
2325
- if (await this.page.locator(selector).isChecked().catch(() => false))
2420
+ if (await this.page
2421
+ .locator(selector)
2422
+ .isChecked()
2423
+ .catch(() => false))
2326
2424
  return true;
2327
2425
  await this.clickAssociatedLabel(selector).catch(() => false);
2328
- if (await this.page.locator(selector).isChecked().catch(() => false))
2426
+ if (await this.page
2427
+ .locator(selector)
2428
+ .isChecked()
2429
+ .catch(() => false))
2329
2430
  return true;
2330
2431
  const domChecked = await this.page
2331
2432
  .locator(selector)
@@ -2350,7 +2451,10 @@ export class BrowserController {
2350
2451
  .catch(() => false);
2351
2452
  if (!domChecked)
2352
2453
  return false;
2353
- return await this.page.locator(selector).isChecked().catch(() => false);
2454
+ return await this.page
2455
+ .locator(selector)
2456
+ .isChecked()
2457
+ .catch(() => false);
2354
2458
  }
2355
2459
  // Click the <label> associated with a checkbox/radio input — either a
2356
2460
  // `<label for="<id>">` or the wrapping `<label>` ancestor. Mantine/Radix
@@ -2552,9 +2656,7 @@ export class BrowserController {
2552
2656
  if (!box.checked)
2553
2657
  return false;
2554
2658
  const text = associatedText(box);
2555
- return (!agreementRe.test(text) &&
2556
- !marketingRe.test(text) &&
2557
- !riskyChoiceRe.test(text));
2659
+ return !agreementRe.test(text) && !marketingRe.test(text) && !riskyChoiceRe.test(text);
2558
2660
  });
2559
2661
  if (alreadyChoseCategory)
2560
2662
  return [];
@@ -2699,8 +2801,7 @@ export class BrowserController {
2699
2801
  // the disabled button is NOT scroll position (Railway iter ≥2 on
2700
2802
  // the second ToS modal: planner kept asking for scroll when the
2701
2803
  // form was actually waiting on something else).
2702
- if (target.scrollTop + target.clientHeight >=
2703
- target.scrollHeight - 4) {
2804
+ if (target.scrollTop + target.clientHeight >= target.scrollHeight - 4) {
2704
2805
  return {
2705
2806
  scrolled: false,
2706
2807
  container: selector ?? "auto-detected",
@@ -2995,8 +3096,8 @@ export class BrowserController {
2995
3096
  '[role="option"]:visible',
2996
3097
  '[role="menuitem"]:visible',
2997
3098
  '[role="menuitemradio"]:visible',
2998
- 'mat-option:visible',
2999
- '.mat-mdc-option:visible',
3099
+ "mat-option:visible",
3100
+ ".mat-mdc-option:visible",
3000
3101
  '[id^="react-select-"][role*="menu"]:visible',
3001
3102
  '[role="listbox"]:visible li:visible',
3002
3103
  ];
@@ -3092,10 +3193,10 @@ export class BrowserController {
3092
3193
  // CSS-escape the id so unusual characters (Sentry's `--` separator
3093
3194
  // is fine, but the helper is defensive against future ids that
3094
3195
  // include `.`, spaces, …) don't break the locator.
3095
- const escaped = (typeof globalThis.CSS?.escape ===
3196
+ const escaped = typeof globalThis.CSS?.escape ===
3096
3197
  "function"
3097
3198
  ? globalThis.CSS.escape(resolvedId)
3098
- : resolvedId.replace(/([!"#$%&'()*+,./:;<=>?@[\\\]^`{|}~])/g, "\\$1"));
3199
+ : resolvedId.replace(/([!"#$%&'()*+,./:;<=>?@[\\\]^`{|}~])/g, "\\$1");
3099
3200
  return `#${escaped}`;
3100
3201
  }
3101
3202
  catch {
@@ -3124,9 +3225,7 @@ export class BrowserController {
3124
3225
  throw new Error("Browser not started");
3125
3226
  const triggerLocator = this.page.locator(triggerSelector);
3126
3227
  try {
3127
- const tagName = await triggerLocator
3128
- .first()
3129
- .evaluate((node) => node.tagName.toLowerCase());
3228
+ const tagName = await triggerLocator.first().evaluate((node) => node.tagName.toLowerCase());
3130
3229
  // Limit this path to input-typed triggers; native <select> and
3131
3230
  // <button role="combobox"> are handled by other tiers. The
3132
3231
  // selectFromCombobox caller has already returned for matching
@@ -3471,9 +3570,7 @@ export class BrowserController {
3471
3570
  out.webglUnmaskedVendor = gl.getParameter(dbg.UNMASKED_VENDOR_WEBGL);
3472
3571
  out.webglUnmaskedRenderer = gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL);
3473
3572
  }
3474
- out.webglExtensions = (gl.getSupportedExtensions() ?? [])
3475
- .slice(0, 6)
3476
- .join(",");
3573
+ out.webglExtensions = (gl.getSupportedExtensions() ?? []).slice(0, 6).join(",");
3477
3574
  }
3478
3575
  else {
3479
3576
  out.webglVendor = null;
@@ -3520,8 +3617,7 @@ export class BrowserController {
3520
3617
  console.error("[fingerprint] " + JSON.stringify(fp));
3521
3618
  }
3522
3619
  catch (err) {
3523
- console.error("[fingerprint] probe failed: " +
3524
- (err instanceof Error ? err.message : String(err)));
3620
+ console.error("[fingerprint] probe failed: " + (err instanceof Error ? err.message : String(err)));
3525
3621
  }
3526
3622
  }
3527
3623
  // Click at the checkbox position. Turnstile's checkbox sits at
@@ -3656,7 +3752,10 @@ export class BrowserController {
3656
3752
  { kind: "turnstile", selector: 'iframe[src*="challenges.cloudflare.com"]' },
3657
3753
  // Visible reCAPTCHA only — the size=invisible anchor (score-mode badge)
3658
3754
  // is handled by the recaptchaInvisibleOnly skip above.
3659
- { kind: "recaptcha", selector: 'iframe[src*="recaptcha/api2/anchor"]:not([src*="size=invisible"])' },
3755
+ {
3756
+ kind: "recaptcha",
3757
+ selector: 'iframe[src*="recaptcha/api2/anchor"]:not([src*="size=invisible"])',
3758
+ },
3660
3759
  // hCaptcha's checkbox iframe (the anchor frame). Plausible and other
3661
3760
  // hCaptcha sites render this; clicking it ticks the box the same way
3662
3761
  // Turnstile/reCAPTCHA do.
@@ -3897,7 +3996,8 @@ export class BrowserController {
3897
3996
  for (const [, v] of Object.entries(obj)) {
3898
3997
  if (v === null || typeof v !== "object")
3899
3998
  continue;
3900
- if ("callback" in v && typeof v.callback === "function") {
3999
+ if ("callback" in v &&
4000
+ typeof v.callback === "function") {
3901
4001
  try {
3902
4002
  v.callback(tok);
3903
4003
  }
@@ -4123,8 +4223,7 @@ export class BrowserController {
4123
4223
  const fromDom = await this.page.evaluate(() => {
4124
4224
  const div = document.querySelector(".h-captcha[data-sitekey], [data-hcaptcha-sitekey]");
4125
4225
  if (div !== null) {
4126
- const k = div.getAttribute("data-sitekey") ??
4127
- div.getAttribute("data-hcaptcha-sitekey");
4226
+ const k = div.getAttribute("data-sitekey") ?? div.getAttribute("data-hcaptcha-sitekey");
4128
4227
  if (k !== null && k.length > 10)
4129
4228
  return k;
4130
4229
  }
@@ -4381,8 +4480,7 @@ export class BrowserController {
4381
4480
  return {
4382
4481
  found: true,
4383
4482
  solved: false,
4384
- reason: `2captcha_${solveRes.kind}` +
4385
- ("reason" in solveRes ? `:${solveRes.reason}` : ""),
4483
+ reason: `2captcha_${solveRes.kind}` + ("reason" in solveRes ? `:${solveRes.reason}` : ""),
4386
4484
  clicks: 0,
4387
4485
  ...("durationMs" in solveRes ? { durationMs: solveRes.durationMs } : {}),
4388
4486
  };
@@ -4563,7 +4661,7 @@ export class BrowserController {
4563
4661
  async extractText() {
4564
4662
  if (!this.page)
4565
4663
  throw new Error("Browser not started");
4566
- return await this.page.textContent("body") || "";
4664
+ return (await this.page.textContent("body")) || "";
4567
4665
  }
4568
4666
  // RENDERED, visibility-respecting body text. extractText() reads
4569
4667
  // textContent("body"), which includes display:none / visibility:hidden /
@@ -4580,6 +4678,201 @@ export class BrowserController {
4580
4678
  throw new Error("Browser not started");
4581
4679
  return await this.page.evaluate(() => document.body?.innerText ?? "");
4582
4680
  }
4681
+ async readCheckoutSummary(fallbackCurrency) {
4682
+ if (!this.page)
4683
+ throw new Error("Browser not started");
4684
+ const page = this.page;
4685
+ const identity = await page.evaluate(() => ({
4686
+ title: document.title,
4687
+ siteName: document.querySelector('meta[property="og:site_name"]')?.content ??
4688
+ document.querySelector('[itemprop="merchant"]')?.textContent ??
4689
+ "",
4690
+ }));
4691
+ const texts = await Promise.all(page
4692
+ .frames()
4693
+ .map(async (frame) => await frame.evaluate(() => document.body?.innerText ?? "").catch(() => "")));
4694
+ const amount = parseCheckoutAmount(texts, fallbackCurrency);
4695
+ if (amount === null)
4696
+ throw new Error("payment_checkout_total_not_found");
4697
+ return {
4698
+ merchant: merchantFromPage(identity.title, identity.siteName, page.url()),
4699
+ checkout_origin: new URL(page.url()).origin,
4700
+ ...amount,
4701
+ };
4702
+ }
4703
+ // Common autocomplete/name selectors across all CDP-reachable frames,
4704
+ // including cross-origin hosted fields. No PSP-specific adapters.
4705
+ async fillAndSubmitCheckout(card) {
4706
+ if (!this.page)
4707
+ throw new Error("Browser not started");
4708
+ const frames = this.page.frames();
4709
+ const filled = new Set();
4710
+ const fillFirst = async (field, value, selectors) => {
4711
+ if (value === undefined || value.length === 0)
4712
+ return false;
4713
+ for (const frame of frames) {
4714
+ const matches = frame.locator(selectors);
4715
+ const count = Math.min(await matches.count().catch(() => 0), 10);
4716
+ for (let i = 0; i < count; i += 1) {
4717
+ const input = matches.nth(i);
4718
+ if (!(await input.isVisible().catch(() => false)))
4719
+ continue;
4720
+ if (!(await input.isEnabled().catch(() => false)))
4721
+ continue;
4722
+ const tag = await input.evaluate((el) => el.tagName.toLowerCase()).catch(() => "");
4723
+ await input.evaluate((el) => el.setAttribute("data-ts-sealed-payment", "1"));
4724
+ if (tag === "select") {
4725
+ const selected = (await input
4726
+ .selectOption({ value })
4727
+ .then(() => true)
4728
+ .catch(() => false)) ||
4729
+ (await input
4730
+ .selectOption({ label: value })
4731
+ .then(() => true)
4732
+ .catch(() => false));
4733
+ if (!selected)
4734
+ continue;
4735
+ }
4736
+ else {
4737
+ await input.fill(value);
4738
+ }
4739
+ filled.add(field);
4740
+ return true;
4741
+ }
4742
+ }
4743
+ return false;
4744
+ };
4745
+ try {
4746
+ await fillFirst("pan", card.pan, 'input[autocomplete~="cc-number"],input[name*="cardnumber" i],input[id*="card-number" i],input[id*="cardnumber" i]');
4747
+ const combinedExpiry = `${card.exp_month.padStart(2, "0")}/${card.exp_year.slice(-2)}`;
4748
+ const combined = await fillFirst("expiry", combinedExpiry, 'input[autocomplete~="cc-exp"],input[name*="expir" i]:not([name*="month" i]):not([name*="year" i]),input[name="exp" i],input[name*="exp-date" i],input[id*="expir" i]:not([id*="month" i]):not([id*="year" i]),input[id="exp" i],input[id*="exp-date" i]');
4749
+ if (!combined) {
4750
+ await fillFirst("exp_month", card.exp_month.padStart(2, "0"), '[autocomplete~="cc-exp-month"],[name*="exp_month" i],[name*="expmonth" i]');
4751
+ await fillFirst("exp_year", card.exp_year, '[autocomplete~="cc-exp-year"],[name*="exp_year" i],[name*="expyear" i]');
4752
+ }
4753
+ const fields = [
4754
+ [
4755
+ "cvv",
4756
+ card.cvv,
4757
+ 'input[autocomplete~="cc-csc"],input[name*="cvv" i],input[name*="cvc" i],input[name*="security-code" i],input[id*="cvv" i],input[id*="cvc" i]',
4758
+ ],
4759
+ [
4760
+ "name",
4761
+ card.name,
4762
+ 'input[autocomplete~="cc-name"],input[name*="cardholder" i],input[name*="card-name" i],input[id*="cardholder" i]',
4763
+ ],
4764
+ [
4765
+ "line1",
4766
+ card.billing.line1,
4767
+ '[autocomplete~="address-line1"],[name*="address_line1" i],[name*="address1" i],[name="line1" i]',
4768
+ ],
4769
+ [
4770
+ "line2",
4771
+ card.billing.line2,
4772
+ '[autocomplete~="address-line2"],[name*="address_line2" i],[name*="address2" i],[name="line2" i]',
4773
+ ],
4774
+ [
4775
+ "city",
4776
+ card.billing.city,
4777
+ '[autocomplete~="address-level2"],[name*="city" i],[name*="locality" i]',
4778
+ ],
4779
+ [
4780
+ "state",
4781
+ card.billing.state,
4782
+ '[autocomplete~="address-level1"],[name*="state" i],[name*="region" i]',
4783
+ ],
4784
+ [
4785
+ "postal_code",
4786
+ card.billing.postal_code,
4787
+ '[autocomplete~="postal-code"],[name*="postal" i],[name*="zip" i]',
4788
+ ],
4789
+ ["country", card.billing.country, '[autocomplete~="country"],[name*="country" i]'],
4790
+ ];
4791
+ for (const [field, value, selectors] of fields) {
4792
+ await fillFirst(field, value, selectors);
4793
+ }
4794
+ for (const required of ["pan", "expiry", "cvv", "name"]) {
4795
+ if (required === "expiry" && filled.has("exp_month") && filled.has("exp_year"))
4796
+ continue;
4797
+ if (!filled.has(required))
4798
+ throw new Error(`payment_field_not_found:${required}`);
4799
+ }
4800
+ const submitName = /^(?:pay(?:\s+now)?|place\s+order|complete\s+(?:order|purchase|payment)|submit\s+payment|buy\s+now|confirm\s+(?:order|payment))\b/i;
4801
+ let submitted = false;
4802
+ for (const frame of frames) {
4803
+ const matches = frame.locator('button,input[type="submit"],[role="button"]');
4804
+ const count = Math.min(await matches.count().catch(() => 0), 100);
4805
+ for (let i = 0; i < count; i += 1) {
4806
+ const candidate = matches.nth(i);
4807
+ if (!(await candidate.isVisible().catch(() => false)))
4808
+ continue;
4809
+ if (!(await candidate.isEnabled().catch(() => false)))
4810
+ continue;
4811
+ const label = await candidate
4812
+ .evaluate((el) => (el.getAttribute("aria-label") ||
4813
+ (el instanceof HTMLInputElement ? el.value : el.textContent) ||
4814
+ "").trim())
4815
+ .catch(() => "");
4816
+ if (!submitName.test(label))
4817
+ continue;
4818
+ await candidate.click();
4819
+ submitted = true;
4820
+ break;
4821
+ }
4822
+ if (submitted)
4823
+ break;
4824
+ }
4825
+ if (!submitted)
4826
+ throw new Error("payment_submit_not_found");
4827
+ const challengeDeadline = Date.now() + 15_000;
4828
+ while (Date.now() < challengeDeadline) {
4829
+ const challenge = await this.detectThreeDsChallenge();
4830
+ if (challenge.three_ds_required)
4831
+ return challenge;
4832
+ await this.page.waitForTimeout(250).catch(() => undefined);
4833
+ }
4834
+ return { three_ds_required: false };
4835
+ }
4836
+ finally {
4837
+ for (const frame of this.page.frames()) {
4838
+ await frame
4839
+ .locator('[data-ts-sealed-payment="1"]')
4840
+ .evaluateAll((elements) => {
4841
+ for (const element of elements) {
4842
+ if (element instanceof HTMLInputElement ||
4843
+ element instanceof HTMLTextAreaElement ||
4844
+ element instanceof HTMLSelectElement) {
4845
+ element.value = "";
4846
+ }
4847
+ element.removeAttribute("data-ts-sealed-payment");
4848
+ }
4849
+ })
4850
+ .catch(() => undefined);
4851
+ }
4852
+ }
4853
+ }
4854
+ async detectThreeDsChallenge() {
4855
+ if (!this.page)
4856
+ throw new Error("Browser not started");
4857
+ const urlPattern = /(?:3d[-_ ]?secure|three[-_ ]?d[-_ ]?secure|\/3ds(?:2)?\/|\/acs\/|challenge)/i;
4858
+ for (const frame of this.page.frames()) {
4859
+ const detected = urlPattern.test(frame.url()) ||
4860
+ (await frame
4861
+ .evaluate(() => {
4862
+ if (document.querySelector('iframe[name*="challenge" i],iframe[title*="3d secure" i],input[name="creq" i],form[action*="acs" i]') !== null)
4863
+ return true;
4864
+ return /\b(?:3d secure|authenticate (?:this )?payment|verify (?:your )?identity|security code sent to)\b/i.test(document.body?.innerText ?? "");
4865
+ })
4866
+ .catch(() => false));
4867
+ if (detected) {
4868
+ return {
4869
+ three_ds_required: true,
4870
+ challenge_url: frame.url() || this.page.url(),
4871
+ };
4872
+ }
4873
+ }
4874
+ return { three_ds_required: false };
4875
+ }
4583
4876
  // Deterministic Firebase/GCP credential extraction. Every Firebase project
4584
4877
  // auto-creates a "Browser key (auto created by Firebase)" in its underlying
4585
4878
  // Google Cloud project — the SAME AIzaSy value as firebaseConfig.apiKey AND a
@@ -4607,8 +4900,7 @@ export class BrowserController {
4607
4900
  }
4608
4901
  // Locate the Firebase Browser-key row; return its AIzaSy if already shown,
4609
4902
  // else click the row's "Show key" button to reveal it.
4610
- const readRowKey = () => this.page
4611
- .evaluate(() => {
4903
+ const readRowKey = () => this.page.evaluate(() => {
4612
4904
  const rows = Array.from(document.querySelectorAll("tr"));
4613
4905
  const row = rows.find((r) => /browser key \(auto created by firebase\)/i.test(r.textContent ?? "")) ??
4614
4906
  rows.find((r) => /browser key/i.test(r.textContent ?? ""));
@@ -4621,8 +4913,7 @@ export class BrowserController {
4621
4913
  if (btn !== undefined)
4622
4914
  btn.click();
4623
4915
  return null;
4624
- })
4625
- .catch(() => null);
4916
+ }).catch(() => null);
4626
4917
  const first = await readRowKey();
4627
4918
  if (first !== null && KEY_RE.test(first))
4628
4919
  return first;
@@ -4702,8 +4993,7 @@ export class BrowserController {
4702
4993
  // "Open Source" but stay uncommitted, so Next stays disabled); (2) empty
4703
4994
  // text; (3) a clear "Select…/Choose…/Pick…" placeholder. NOT
4704
4995
  // "search"/"add"/"type" — those are filter inputs we must not auto-pick.
4705
- const hasPlaceholderAttr = el.hasAttribute("data-placeholder") ||
4706
- el.querySelector("[data-placeholder]") !== null;
4996
+ const hasPlaceholderAttr = el.hasAttribute("data-placeholder") || el.querySelector("[data-placeholder]") !== null;
4707
4997
  const placeholderish = hasPlaceholderAttr ||
4708
4998
  txt.length === 0 ||
4709
4999
  /^(?:please\s+)?(?:select|choose|pick)\b/i.test(txt);
@@ -4826,7 +5116,9 @@ export class BrowserController {
4826
5116
  // triggers (aria-haspopup) — those are handled in (2); a selected preset
4827
5117
  // trigger can also read "All access" and we must not re-open it here.
4828
5118
  try {
4829
- const allAccess = root.locator('button:not([aria-haspopup="true"])', { hasText: /^(?:all access|full access|all scopes)$/i });
5119
+ const allAccess = root.locator('button:not([aria-haspopup="true"])', {
5120
+ hasText: /^(?:all access|full access|all scopes)$/i,
5121
+ });
4830
5122
  const n = Math.min(await allAccess.count().catch(() => 0), 3);
4831
5123
  for (let i = 0; i < n; i += 1) {
4832
5124
  const b = allAccess.nth(i);
@@ -4848,9 +5140,7 @@ export class BrowserController {
4848
5140
  const t = triggers.nth(i);
4849
5141
  if (!(await t.isVisible().catch(() => false)))
4850
5142
  continue;
4851
- const txt = ((await t.textContent().catch(() => "")) ?? "")
4852
- .replace(/\s+/g, " ")
4853
- .trim();
5143
+ const txt = ((await t.textContent().catch(() => "")) ?? "").replace(/\s+/g, " ").trim();
4854
5144
  // Only act on an UNSELECTED select (a "Select…/Choose…/Pick…"
4855
5145
  // placeholder) — never re-pick one that already holds a value.
4856
5146
  if (!/^(?:please\s+)?(?:select|choose|pick)\b/i.test(txt))
@@ -4919,7 +5209,9 @@ export class BrowserController {
4919
5209
  if (!this.page)
4920
5210
  throw new Error("Browser not started");
4921
5211
  return await this.page.evaluate(async (rawPrefix) => {
4922
- const prefix = String(rawPrefix ?? "").replace(/^\/+|\/+$/g, "").toLowerCase();
5212
+ const prefix = String(rawPrefix ?? "")
5213
+ .replace(/^\/+|\/+$/g, "")
5214
+ .toLowerCase();
4923
5215
  const candidates = [];
4924
5216
  const seen = new Set();
4925
5217
  const add = (value) => {
@@ -5182,29 +5474,54 @@ export class BrowserController {
5182
5474
  return await this.page.evaluate(() => {
5183
5475
  const LABEL_PHRASES = [
5184
5476
  // Generic
5185
- "api key", "api token", "api secret", "secret key", "access key",
5186
- "access token", "auth token", "bearer token", "personal access token",
5187
- "client id", "client secret", "client key",
5477
+ "api key",
5478
+ "api token",
5479
+ "api secret",
5480
+ "secret key",
5481
+ "access key",
5482
+ "access token",
5483
+ "auth token",
5484
+ "bearer token",
5485
+ "personal access token",
5486
+ "client id",
5487
+ "client secret",
5488
+ "client key",
5188
5489
  // Cloudinary
5189
- "cloud name", "cloudname",
5490
+ "cloud name",
5491
+ "cloudname",
5190
5492
  // Algolia
5191
- "application id", "app id", "admin api key", "search api key",
5192
- "monitoring api key", "search-only api key",
5493
+ "application id",
5494
+ "app id",
5495
+ "admin api key",
5496
+ "search api key",
5497
+ "monitoring api key",
5498
+ "search-only api key",
5193
5499
  // Twilio
5194
- "account sid", "auth token",
5500
+ "account sid",
5501
+ "auth token",
5195
5502
  // Stripe
5196
- "publishable key", "secret key",
5503
+ "publishable key",
5504
+ "secret key",
5197
5505
  // AWS
5198
- "access key id", "secret access key",
5506
+ "access key id",
5507
+ "secret access key",
5199
5508
  // OAuth1
5200
- "consumer key", "consumer secret", "access token secret",
5509
+ "consumer key",
5510
+ "consumer secret",
5511
+ "access token secret",
5201
5512
  // Misc
5202
- "project api key", "personal api key", "organization id", "org id",
5203
- "app key", "app secret",
5513
+ "project api key",
5514
+ "personal api key",
5515
+ "organization id",
5516
+ "org id",
5517
+ "app key",
5518
+ "app secret",
5204
5519
  // Pusher (and other keys tables) label fields bare: key / secret /
5205
5520
  // cluster. Without these the value inherits the nearest recognized
5206
5521
  // label (the app_id field), mislabeling key + secret as "app id".
5207
- "cluster", "key", "secret",
5522
+ "cluster",
5523
+ "key",
5524
+ "secret",
5208
5525
  ];
5209
5526
  const isVisible = (el) => {
5210
5527
  const r = el.getBoundingClientRect();
@@ -5397,14 +5714,11 @@ export class BrowserController {
5397
5714
  }
5398
5715
  // 1. <input> / <textarea> values (visible only).
5399
5716
  document.querySelectorAll("input, textarea").forEach((el) => {
5400
- if (el instanceof HTMLInputElement &&
5401
- (el.type === "hidden" || el.type === "password"))
5717
+ if (el instanceof HTMLInputElement && (el.type === "hidden" || el.type === "password"))
5402
5718
  return;
5403
5719
  if (!isVisible(el))
5404
5720
  return;
5405
- const value = el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement
5406
- ? el.value
5407
- : "";
5721
+ const value = el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement ? el.value : "";
5408
5722
  if (value.length > 0)
5409
5723
  pushCandidate(value, el);
5410
5724
  });
@@ -5426,9 +5740,7 @@ export class BrowserController {
5426
5740
  });
5427
5741
  // 3. Structural containers (code/pre/kbd) where the credential
5428
5742
  // is interpolated through nested spans.
5429
- document
5430
- .querySelectorAll('code, pre, kbd, samp, [role="textbox"]')
5431
- .forEach((el) => {
5743
+ document.querySelectorAll('code, pre, kbd, samp, [role="textbox"]').forEach((el) => {
5432
5744
  if (!isVisible(el))
5433
5745
  return;
5434
5746
  const full = (el.textContent ?? "").trim();
@@ -5488,9 +5800,7 @@ export class BrowserController {
5488
5800
  return;
5489
5801
  masked.push({ el, row: rowAncestor(el) });
5490
5802
  });
5491
- document
5492
- .querySelectorAll('input[type="password"]')
5493
- .forEach((el) => {
5803
+ document.querySelectorAll('input[type="password"]').forEach((el) => {
5494
5804
  if (!isVisible(el))
5495
5805
  return;
5496
5806
  masked.push({ el, row: rowAncestor(el) });
@@ -5576,7 +5886,12 @@ export class BrowserController {
5576
5886
  const btn = showBtns[0];
5577
5887
  const sel = selectorFor(btn);
5578
5888
  selectors.push(sel);
5579
- const label = (btn.textContent ?? btn.getAttribute("aria-label") ?? btn.getAttribute("title") ?? "").trim().slice(0, 40);
5889
+ const label = (btn.textContent ??
5890
+ btn.getAttribute("aria-label") ??
5891
+ btn.getAttribute("title") ??
5892
+ "")
5893
+ .trim()
5894
+ .slice(0, 40);
5580
5895
  diagnostic.push(`row→show:"${label}"→${sel}`);
5581
5896
  }
5582
5897
  else if (copyBtns.length > 0) {
@@ -5624,9 +5939,7 @@ export class BrowserController {
5624
5939
  !["text", "search", "url", "tel", "number", "email", ""].includes(el.type)) {
5625
5940
  return;
5626
5941
  }
5627
- const value = el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement
5628
- ? el.value
5629
- : "";
5942
+ const value = el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement ? el.value : "";
5630
5943
  if (value.trim().length > 0 && isVisible(el))
5631
5944
  out.push(value.trim());
5632
5945
  });
@@ -5650,9 +5963,7 @@ export class BrowserController {
5650
5963
  // <span>s — the loop above sees an empty direct-text and skips
5651
5964
  // them. Push the full textContent so a UUID built as
5652
5965
  // <code><span>7</span><span>5</span>…</code> is still scannable.
5653
- document
5654
- .querySelectorAll('code, pre, kbd, samp, [role="textbox"]')
5655
- .forEach((el) => {
5966
+ document.querySelectorAll('code, pre, kbd, samp, [role="textbox"]').forEach((el) => {
5656
5967
  if (!isVisible(el))
5657
5968
  return;
5658
5969
  const full = (el.textContent ?? "").trim();
@@ -5881,9 +6192,7 @@ export class BrowserController {
5881
6192
  // (e.g. lazy-rendering the previously-blocked OAuth chooser).
5882
6193
  // Try networkidle first for SPA re-renders, fall back to a
5883
6194
  // fixed dwell.
5884
- await this.page
5885
- .waitForLoadState("networkidle", { timeout: 3000 })
5886
- .catch(() => undefined);
6195
+ await this.page.waitForLoadState("networkidle", { timeout: 3000 }).catch(() => undefined);
5887
6196
  await this.page.waitForTimeout(800);
5888
6197
  return target.text;
5889
6198
  }
@@ -6110,9 +6419,7 @@ export class BrowserController {
6110
6419
  if (r.width < 2 || r.height < 2)
6111
6420
  return false;
6112
6421
  const s = window.getComputedStyle(el);
6113
- return (s.display !== "none" &&
6114
- s.visibility !== "hidden" &&
6115
- parseFloat(s.opacity || "1") > 0.01);
6422
+ return (s.display !== "none" && s.visibility !== "hidden" && parseFloat(s.opacity || "1") > 0.01);
6116
6423
  };
6117
6424
  // G12 — visually-hidden checkbox/radio surfacing. Custom-styled
6118
6425
  // TOS checkboxes are real `<input type=checkbox>` elements with
@@ -6246,11 +6553,15 @@ export class BrowserController {
6246
6553
  const id = el.getAttribute("id");
6247
6554
  const name = el.getAttribute("name");
6248
6555
  if (testId !== null && testId.length > 0) {
6249
- const attr = el.hasAttribute("data-testid") ? "data-testid" :
6250
- el.hasAttribute("data-test-id") ? "data-test-id" :
6251
- el.hasAttribute("data-test") ? "data-test" :
6252
- el.hasAttribute("data-cy") ? "data-cy" :
6253
- "data-qa";
6556
+ const attr = el.hasAttribute("data-testid")
6557
+ ? "data-testid"
6558
+ : el.hasAttribute("data-test-id")
6559
+ ? "data-test-id"
6560
+ : el.hasAttribute("data-test")
6561
+ ? "data-test"
6562
+ : el.hasAttribute("data-cy")
6563
+ ? "data-cy"
6564
+ : "data-qa";
6254
6565
  base = `[${attr}="${CSS.escape(testId)}"]`;
6255
6566
  }
6256
6567
  else if (id !== null && /^[A-Za-z][\w-]*$/.test(id)) {
@@ -6390,8 +6701,12 @@ export class BrowserController {
6390
6701
  // cursor:pointer but no button/role/input semantics and no div/section
6391
6702
  // wrapper, so the SELECTOR walk AND the old div-only scan both missed
6392
6703
  // it, leaving the planner no clickable target.
6393
- const isCardTag = (t) => t === "div" || t === "li" || t === "article" ||
6394
- t === "section" || t === "label" || t.includes("-");
6704
+ const isCardTag = (t) => t === "div" ||
6705
+ t === "li" ||
6706
+ t === "article" ||
6707
+ t === "section" ||
6708
+ t === "label" ||
6709
+ t.includes("-");
6395
6710
  // Walk the light DOM AND every open shadow root — a UI-kit chip can
6396
6711
  // live inside a web component's shadow tree.
6397
6712
  const scanRoot = (root) => {
@@ -6516,22 +6831,18 @@ export class BrowserController {
6516
6831
  const pathLabel = isGoogleGSIIframe
6517
6832
  ? "Continue with Google"
6518
6833
  : isFormControlElement(el)
6519
- ? labelFor(el) ?? directLabel(el) ?? iconLabelFor(el)
6520
- : directLabel(el) ?? labelFor(el) ?? iconLabelFor(el);
6834
+ ? (labelFor(el) ?? directLabel(el) ?? iconLabelFor(el))
6835
+ : (directLabel(el) ?? labelFor(el) ?? iconLabelFor(el));
6521
6836
  out.push({
6522
6837
  tag: isGoogleGSIIframe ? "button" : el.tagName.toLowerCase(),
6523
6838
  type: el.getAttribute("type"),
6524
6839
  id: el.getAttribute("id"),
6525
6840
  name: el.getAttribute("name"),
6526
6841
  placeholder: el.getAttribute("placeholder"),
6527
- ariaLabel: isGoogleGSIIframe
6528
- ? "Continue with Google"
6529
- : el.getAttribute("aria-label"),
6842
+ ariaLabel: isGoogleGSIIframe ? "Continue with Google" : el.getAttribute("aria-label"),
6530
6843
  role: isGoogleGSIIframe ? "button" : el.getAttribute("role"),
6531
6844
  labelText: labelFor(el),
6532
- visibleText: isGoogleGSIIframe
6533
- ? "Continue with Google"
6534
- : clean(el.textContent),
6845
+ visibleText: isGoogleGSIIframe ? "Continue with Google" : clean(el.textContent),
6535
6846
  selector: selectorFor(el),
6536
6847
  visible: true,
6537
6848
  inViewport: r.top >= 0 &&
@@ -6573,8 +6884,7 @@ export class BrowserController {
6573
6884
  // caller wanting to find UNCHECKED checkboxes needs `checked`
6574
6885
  // explicitly. The submit-disabled re-plan hint uses this to
6575
6886
  // surface concrete unticked candidates to the planner.
6576
- checked: el instanceof HTMLInputElement &&
6577
- (el.type === "checkbox" || el.type === "radio")
6887
+ checked: el instanceof HTMLInputElement && (el.type === "checkbox" || el.type === "radio")
6578
6888
  ? el.checked
6579
6889
  : null,
6580
6890
  // For <select>: the currently-selected option's visible text
@@ -6600,6 +6910,7 @@ export class BrowserController {
6600
6910
  ? clean(el.options[el.selectedIndex]?.textContent ?? null)
6601
6911
  : null,
6602
6912
  interactedThisRun: el.getAttribute("data-ts-touched") === "1",
6913
+ sealed: el.getAttribute("data-ts-sealed-payment") === "1",
6603
6914
  screenPath: `${container ?? "body:root"} > ${elementKind(el)}:` +
6604
6915
  slug(pathLabel, `${elementKind(el)}-${out.length}`),
6605
6916
  container,
@@ -6658,9 +6969,7 @@ export class BrowserController {
6658
6969
  }
6659
6970
  // Race a popup `page` event against the click. context-level
6660
6971
  // "page" fires for both window.open popups and target=_blank.
6661
- const popupPromise = this.context
6662
- .waitForEvent("page", { timeout: 8000 })
6663
- .catch(() => null);
6972
+ const popupPromise = this.context.waitForEvent("page", { timeout: 8000 }).catch(() => null);
6664
6973
  await this.click(selector);
6665
6974
  const popup = await popupPromise;
6666
6975
  if (popup !== null && popup !== this.page && !popup.isClosed()) {
@@ -6706,7 +7015,8 @@ export class BrowserController {
6706
7015
  // Capture the body of a Clerk/Stytch/WorkOS sign-in/up/callback error
6707
7016
  // (>=400) — its error code is the definitive tell (captcha_invalid vs
6708
7017
  // transfer vs identifier_*). JSON only, bounded.
6709
- if (res.status() >= 400 && /\/v1\/client\/(sign_ins|sign_ups)|oauth_callback|\/session/i.test(url)) {
7018
+ if (res.status() >= 400 &&
7019
+ /\/v1\/client\/(sign_ins|sign_ups)|oauth_callback|\/session/i.test(url)) {
6710
7020
  entry.body = (await res.text().catch(() => "")).slice(0, 800);
6711
7021
  }
6712
7022
  this.oauthNetLog.push(entry);
@@ -6770,7 +7080,15 @@ export class BrowserController {
6770
7080
  mkdirSync(dir, { recursive: true });
6771
7081
  const ts = process.env.OAUTH_DEBUG_TS ?? String(this.oauthNetLog.length);
6772
7082
  const path = join(dir, `${service}-${label}-${ts}.json`);
6773
- writeFileSync(path, JSON.stringify({ service, label, finalUrl: url, clerkState, cookies: cookieSummary, netLog: this.oauthNetLog, pageText: consoleText.slice(0, 600) }, null, 2));
7083
+ writeFileSync(path, JSON.stringify({
7084
+ service,
7085
+ label,
7086
+ finalUrl: url,
7087
+ clerkState,
7088
+ cookies: cookieSummary,
7089
+ netLog: this.oauthNetLog,
7090
+ pageText: consoleText.slice(0, 600),
7091
+ }, null, 2));
6774
7092
  console.error(`[oauth-debug] wrote ${path} (${cookieSummary.length} cookies, ${this.oauthNetLog.length} net entries)`);
6775
7093
  }
6776
7094
  catch (err) {
@@ -6785,9 +7103,7 @@ export class BrowserController {
6785
7103
  return null;
6786
7104
  try {
6787
7105
  return await this.page.evaluate(() => {
6788
- const c = document
6789
- .querySelector('meta[name="csrf-token"]')
6790
- ?.getAttribute("content");
7106
+ const c = document.querySelector('meta[name="csrf-token"]')?.getAttribute("content");
6791
7107
  return c !== null && c !== undefined && c.length > 0 ? c : null;
6792
7108
  });
6793
7109
  }
@@ -6959,9 +7275,7 @@ export class BrowserController {
6959
7275
  // undefined (no-op) and any failure must degrade to the popup/none path.
6960
7276
  if (cdp !== null) {
6961
7277
  const promptDeadline = Date.now() + Math.min(4_000, timeoutMs);
6962
- while (Date.now() < promptDeadline &&
6963
- !fedcmResolved &&
6964
- this.context.pages().length <= 1) {
7278
+ while (Date.now() < promptDeadline && !fedcmResolved && this.context.pages().length <= 1) {
6965
7279
  await this.sleep(250);
6966
7280
  }
6967
7281
  if (!fedcmResolved && this.context.pages().length <= 1) {
@@ -7252,7 +7566,7 @@ export class BrowserController {
7252
7566
  const bad = /\b(settings|marketplace|learn more|cancel|skip|back|terms|privacy)\b/i;
7253
7567
  const candidates = Array.from(document.querySelectorAll('a[href], button, [role="button"], [role="link"]')).filter((el) => visible(el));
7254
7568
  const byHref = candidates.find((el) => {
7255
- const href = el instanceof HTMLAnchorElement ? el.href : el.getAttribute("href") ?? "";
7569
+ const href = el instanceof HTMLAnchorElement ? el.href : (el.getAttribute("href") ?? "");
7256
7570
  return /\/installations\/(?:new|permissions)\b/.test(href);
7257
7571
  });
7258
7572
  const target = byHref ??
@@ -7562,7 +7876,9 @@ export class BrowserController {
7562
7876
  try {
7563
7877
  this.xvfb.stop();
7564
7878
  }
7565
- catch { /* best-effort */ }
7879
+ catch {
7880
+ /* best-effort */
7881
+ }
7566
7882
  this.xvfb = null;
7567
7883
  }
7568
7884
  }
@@ -7880,9 +8196,7 @@ export function scoreSignupButton(text, oauthProviders) {
7880
8196
  // Bump weight from +5 to +12 so the combined-flow button outranks
7881
8197
  // generic nav anchors that score 0. The compensating auth-verb
7882
8198
  // penalty below is also suppressed when email is present.
7883
- const hasEmail = t.includes("continue with email") ||
7884
- t.includes("sign up with email") ||
7885
- t.includes("email");
8199
+ const hasEmail = t.includes("continue with email") || t.includes("sign up with email") || t.includes("email");
7886
8200
  if (hasEmail) {
7887
8201
  score += 12;
7888
8202
  }