@trusty-squire/mcp 1.1.6 → 1.1.7
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 +34 -16
- package/dist/api-client.d.ts +11 -3
- package/dist/api-client.d.ts.map +1 -1
- package/dist/api-client.js +14 -4
- package/dist/api-client.js.map +1 -1
- package/dist/bot/browser.d.ts +19 -1
- package/dist/bot/browser.d.ts.map +1 -1
- package/dist/bot/browser.js +228 -30
- package/dist/bot/browser.js.map +1 -1
- package/dist/bot/near-text-hint.d.ts +7 -3
- package/dist/bot/near-text-hint.d.ts.map +1 -1
- package/dist/bot/near-text-hint.js +1 -2
- package/dist/bot/near-text-hint.js.map +1 -1
- package/dist/bot/operator-recipe.d.ts +694 -36
- package/dist/bot/operator-recipe.d.ts.map +1 -1
- package/dist/bot/operator-recipe.js +565 -43
- package/dist/bot/operator-recipe.js.map +1 -1
- package/dist/bot/pay-operator.d.ts +4 -2
- package/dist/bot/pay-operator.d.ts.map +1 -1
- package/dist/bot/pay-operator.js +173 -67
- package/dist/bot/pay-operator.js.map +1 -1
- package/dist/bot/promote-to-skill.d.ts +1 -0
- package/dist/bot/promote-to-skill.d.ts.map +1 -1
- package/dist/bot/promote-to-skill.js +20 -30
- package/dist/bot/promote-to-skill.js.map +1 -1
- package/dist/bot/provision-session.d.ts +44 -1
- package/dist/bot/provision-session.d.ts.map +1 -1
- package/dist/bot/provision-session.js +1300 -104
- package/dist/bot/provision-session.js.map +1 -1
- package/dist/eval/replay-harness/corpus.d.ts +7 -0
- package/dist/eval/replay-harness/corpus.d.ts.map +1 -0
- package/dist/eval/replay-harness/corpus.js +183 -0
- package/dist/eval/replay-harness/corpus.js.map +1 -0
- package/dist/eval/replay-harness/har-mutate.d.ts +36 -0
- package/dist/eval/replay-harness/har-mutate.d.ts.map +1 -0
- package/dist/eval/replay-harness/har-mutate.js +119 -0
- package/dist/eval/replay-harness/har-mutate.js.map +1 -0
- package/dist/eval/replay-harness/har-substrate.d.ts +16 -0
- package/dist/eval/replay-harness/har-substrate.d.ts.map +1 -0
- package/dist/eval/replay-harness/har-substrate.js +99 -0
- package/dist/eval/replay-harness/har-substrate.js.map +1 -0
- package/dist/eval/replay-harness/index.d.ts +8 -0
- package/dist/eval/replay-harness/index.d.ts.map +1 -0
- package/dist/eval/replay-harness/index.js +8 -0
- package/dist/eval/replay-harness/index.js.map +1 -0
- package/dist/eval/replay-harness/metrics.d.ts +9 -0
- package/dist/eval/replay-harness/metrics.d.ts.map +1 -0
- package/dist/eval/replay-harness/metrics.js +117 -0
- package/dist/eval/replay-harness/metrics.js.map +1 -0
- package/dist/eval/replay-harness/reporter.d.ts +4 -0
- package/dist/eval/replay-harness/reporter.d.ts.map +1 -0
- package/dist/eval/replay-harness/reporter.js +62 -0
- package/dist/eval/replay-harness/reporter.js.map +1 -0
- package/dist/eval/replay-harness/runner.d.ts +36 -0
- package/dist/eval/replay-harness/runner.d.ts.map +1 -0
- package/dist/eval/replay-harness/runner.js +167 -0
- package/dist/eval/replay-harness/runner.js.map +1 -0
- package/dist/eval/replay-harness/types.d.ts +135 -0
- package/dist/eval/replay-harness/types.d.ts.map +1 -0
- package/dist/eval/replay-harness/types.js +2 -0
- package/dist/eval/replay-harness/types.js.map +1 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +1 -0
- package/dist/server.js.map +1 -1
- package/dist/tools/grant-app-access.js +1 -1
- package/dist/tools/grant-app-access.js.map +1 -1
- package/dist/tools/operate-pay.d.ts +10 -10
- package/dist/tools/operate-pay.d.ts.map +1 -1
- package/dist/tools/operate-pay.js +42 -16
- package/dist/tools/operate-pay.js.map +1 -1
- package/dist/tools/provision-drive.d.ts +165 -8
- package/dist/tools/provision-drive.d.ts.map +1 -1
- package/dist/tools/provision-drive.js +211 -40
- package/dist/tools/provision-drive.js.map +1 -1
- package/package.json +2 -1
package/dist/bot/browser.js
CHANGED
|
@@ -109,6 +109,24 @@ function getChromium() {
|
|
|
109
109
|
}
|
|
110
110
|
return cachedChromium;
|
|
111
111
|
}
|
|
112
|
+
export function hasPayPalHostedCheckoutFrame(frames) {
|
|
113
|
+
return frames.some((frame) => {
|
|
114
|
+
const marker = `${frame.url} ${frame.name} ${frame.title}`.toLowerCase();
|
|
115
|
+
if (/__zoid__paypal_(?:buttons|card_fields|checkout)/.test(marker))
|
|
116
|
+
return true;
|
|
117
|
+
try {
|
|
118
|
+
const url = new URL(frame.url);
|
|
119
|
+
const paypalHost = url.hostname === "paypal.com" ||
|
|
120
|
+
url.hostname.endsWith(".paypal.com") ||
|
|
121
|
+
url.hostname === "paypalobjects.com" ||
|
|
122
|
+
url.hostname.endsWith(".paypalobjects.com");
|
|
123
|
+
return paypalHost && /(?:smart|checkout|card[_ -]?fields|zoid)/.test(marker);
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
}
|
|
112
130
|
const CURRENCY_SYMBOLS = {
|
|
113
131
|
$: "USD",
|
|
114
132
|
"€": "EUR",
|
|
@@ -494,7 +512,8 @@ async function withChromeStartupLock(fn) {
|
|
|
494
512
|
}
|
|
495
513
|
const selfManagedChromePids = new Set();
|
|
496
514
|
let selfManagedCleanupInstalled = false;
|
|
497
|
-
let
|
|
515
|
+
let orphanVerifierReapRan = false;
|
|
516
|
+
const orphanOperatorProfilesReaped = new Set();
|
|
498
517
|
function killPid(pid, signal) {
|
|
499
518
|
try {
|
|
500
519
|
process.kill(pid, signal);
|
|
@@ -530,18 +549,44 @@ function registerSelfManagedChrome(child) {
|
|
|
530
549
|
selfManagedChromePids.delete(child.pid);
|
|
531
550
|
});
|
|
532
551
|
}
|
|
533
|
-
// Stale verifier browsers are the expensive leak mode: if the MCP process dies
|
|
552
|
+
// Stale verifier/operator browsers are the expensive leak mode: if the MCP process dies
|
|
534
553
|
// mid-verify, self-launched Chrome survives as PPID=1 with a
|
|
535
|
-
// ~/.trusty-squire/profiles/verify-*
|
|
554
|
+
// ~/.trusty-squire/profiles/verify-* or ~/.trusty-squire/chrome-profile
|
|
555
|
+
// user-data-dir. It keeps the profile lock,
|
|
536
556
|
// burns memory, and may leave defunct helper children. A live verifier should
|
|
537
557
|
// never be parented to init, so these are safe to reap at the next browser
|
|
538
558
|
// startup. Best-effort and Linux-only; failure must not block signups.
|
|
539
|
-
function
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
559
|
+
function escapeRegExp(value) {
|
|
560
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
561
|
+
}
|
|
562
|
+
export function matchesReapableBrowserArgs(args, profileDirs, includeVerifier) {
|
|
563
|
+
if (!/(?:chrome|chromium)/i.test(args))
|
|
564
|
+
return false;
|
|
565
|
+
const profiles = [...new Set(profileDirs)].map(escapeRegExp);
|
|
566
|
+
if (includeVerifier) {
|
|
567
|
+
profiles.unshift(String.raw `[^\s"']*\.trusty-squire\/profiles\/verify-[^\/\s"']+`);
|
|
568
|
+
}
|
|
569
|
+
if (profiles.length === 0)
|
|
570
|
+
return false;
|
|
571
|
+
const profile = `(?:${profiles.join("|")})`;
|
|
572
|
+
return new RegExp(`--user-data-dir=(?:"${profile}"|'${profile}'|${profile})(?=\\s|$)`).test(args);
|
|
573
|
+
}
|
|
574
|
+
export function claimOrphanBrowserReapScope(profileDir) {
|
|
575
|
+
const includeVerifier = !orphanVerifierReapRan;
|
|
576
|
+
const profileDirs = [...new Set([CHROME_PROFILE_DIR, profileDir])].filter((candidate) => !orphanOperatorProfilesReaped.has(candidate));
|
|
577
|
+
if (!includeVerifier && profileDirs.length === 0)
|
|
578
|
+
return null;
|
|
579
|
+
orphanVerifierReapRan = true;
|
|
580
|
+
for (const candidate of profileDirs)
|
|
581
|
+
orphanOperatorProfilesReaped.add(candidate);
|
|
582
|
+
return { includeVerifier, profileDirs };
|
|
583
|
+
}
|
|
584
|
+
function reapOrphanedBrowsersOnce(profileDir) {
|
|
543
585
|
if (process.platform !== "linux")
|
|
544
586
|
return;
|
|
587
|
+
const scope = claimOrphanBrowserReapScope(profileDir);
|
|
588
|
+
if (scope === null)
|
|
589
|
+
return;
|
|
545
590
|
let rows = "";
|
|
546
591
|
try {
|
|
547
592
|
rows = execFileSync("ps", ["-eo", "pid=,ppid=,args="], {
|
|
@@ -561,14 +606,13 @@ function reapOrphanedVerifyBrowsersOnce() {
|
|
|
561
606
|
const args = match[3] ?? "";
|
|
562
607
|
if (Number.isFinite(pid) &&
|
|
563
608
|
ppid === 1 &&
|
|
564
|
-
|
|
565
|
-
/--user-data-dir=.*\.trusty-squire\/profiles\/verify-[^/\s]+/.test(args)) {
|
|
609
|
+
matchesReapableBrowserArgs(args, scope.profileDirs, scope.includeVerifier)) {
|
|
566
610
|
pids.push(pid);
|
|
567
611
|
}
|
|
568
612
|
}
|
|
569
613
|
if (pids.length === 0)
|
|
570
614
|
return;
|
|
571
|
-
console.error(`[operator] reaping ${pids.length} orphaned
|
|
615
|
+
console.error(`[operator] reaping ${pids.length} orphaned Chrome process(es)`);
|
|
572
616
|
for (const pid of pids)
|
|
573
617
|
killPid(pid, "SIGTERM");
|
|
574
618
|
setTimeout(() => {
|
|
@@ -805,6 +849,11 @@ export class BrowserController {
|
|
|
805
849
|
// Google session across runs — see profile.ts / google-login.ts.
|
|
806
850
|
context = null;
|
|
807
851
|
page = null;
|
|
852
|
+
// The page start() configured with the controller's navigation/captcha
|
|
853
|
+
// handlers. OAuth may temporarily switch `this.page` to a popup, but warm
|
|
854
|
+
// reuse must always restore this original page rather than adopting a popup
|
|
855
|
+
// whose lifecycle handlers were never installed.
|
|
856
|
+
primaryPage = null;
|
|
808
857
|
// Self-launch path (Turnstile-safe; see selfLaunchEnabled). When we spawn
|
|
809
858
|
// Chrome ourselves and attach over CDP, these hold the child process and
|
|
810
859
|
// the connected Browser so close() can tear both down.
|
|
@@ -865,6 +914,20 @@ export class BrowserController {
|
|
|
865
914
|
// Per-launch egress override (verify-fleet identities each get their own IP).
|
|
866
915
|
// null → use the env-global proxy. See resolveProxy().
|
|
867
916
|
proxyOverride;
|
|
917
|
+
// Warm reuse is deliberately narrow: a browser belongs to exactly one
|
|
918
|
+
// persistent profile and one explicit egress override. Undefined/blank proxy
|
|
919
|
+
// values both mean "use the environment/default route".
|
|
920
|
+
matchesLaunchOptions(opts) {
|
|
921
|
+
const profileDir = opts.profileDir ?? CHROME_PROFILE_DIR;
|
|
922
|
+
const proxyUrl = opts.proxyUrl !== undefined && opts.proxyUrl.trim().length > 0 ? opts.proxyUrl.trim() : null;
|
|
923
|
+
return this.profileDir === profileDir && this.proxyOverride === proxyUrl;
|
|
924
|
+
}
|
|
925
|
+
// Required health gate for a warm browser. BrowserContext alone is not a
|
|
926
|
+
// sufficient signal: a dead CDP transport can leave stale JS objects behind.
|
|
927
|
+
isConnected() {
|
|
928
|
+
const browser = this.cdpBrowser ?? this.context?.browser() ?? null;
|
|
929
|
+
return browser?.isConnected() === true;
|
|
930
|
+
}
|
|
868
931
|
// Which browser channel the most recent .start() actually used.
|
|
869
932
|
// `null` means bundled Chromium; a string like "chrome" means a
|
|
870
933
|
// real installed browser of that channel. Throws if .start() hasn't
|
|
@@ -1072,7 +1135,7 @@ export class BrowserController {
|
|
|
1072
1135
|
console.error("[operator] resource blocking ON (image/media/font + analytics aborted; captcha/CSS/JS allowed)");
|
|
1073
1136
|
}
|
|
1074
1137
|
async start() {
|
|
1075
|
-
|
|
1138
|
+
reapOrphanedBrowsersOnce(this.profileDir);
|
|
1076
1139
|
const channel = await detectChromiumChannel();
|
|
1077
1140
|
this.launchedChannel = channel;
|
|
1078
1141
|
const proxy = await this.resolveProxy();
|
|
@@ -1427,6 +1490,7 @@ export class BrowserController {
|
|
|
1427
1490
|
if (!remoteMode)
|
|
1428
1491
|
await context.addInitScript({ content: installWebglSpoofScript });
|
|
1429
1492
|
this.page = context.pages()[0] ?? (await context.newPage());
|
|
1493
|
+
this.primaryPage = this.page;
|
|
1430
1494
|
// addInitScript covers document-start page JS, but Playwright's
|
|
1431
1495
|
// page.evaluate utility execution can run in a separate realm. Install the
|
|
1432
1496
|
// same no-op helper there with a STRING evaluate (tsx cannot wrap strings
|
|
@@ -3301,6 +3365,13 @@ export class BrowserController {
|
|
|
3301
3365
|
}
|
|
3302
3366
|
}
|
|
3303
3367
|
if (tagName === "select") {
|
|
3368
|
+
// Keep the resolved target as a Locator. Walker selectors can include
|
|
3369
|
+
// Playwright chains such as `select.foo >> nth=1`; appending CSS text to
|
|
3370
|
+
// those strings changes the chain's meaning (`... >> nth=1 option`) and
|
|
3371
|
+
// makes a full select appear option-less. Descendant lookup, selection,
|
|
3372
|
+
// and verification must all stay anchored to the same resolved element.
|
|
3373
|
+
const selectLocator = this.page.locator(activeSelector).first();
|
|
3374
|
+
const optionLocator = selectLocator.locator("option");
|
|
3304
3375
|
// Native path. rc.15 — keep value="" options selectable. The
|
|
3305
3376
|
// Railway workspace dropdown's "No workspace" option is value=""
|
|
3306
3377
|
// and it IS the right pick for an account-scoped token. The
|
|
@@ -3309,9 +3380,7 @@ export class BrowserController {
|
|
|
3309
3380
|
// never reach that option. Now: fallback list keeps every option
|
|
3310
3381
|
// (with the first option's value, even if empty), and a matched
|
|
3311
3382
|
// text-based pick is honored verbatim — including empty values.
|
|
3312
|
-
const allValues = await
|
|
3313
|
-
.locator(`${activeSelector} option`)
|
|
3314
|
-
.evaluateAll((opts) => opts.map((o) => (o instanceof HTMLOptionElement ? o.value : "")));
|
|
3383
|
+
const allValues = await optionLocator.evaluateAll((opts) => opts.map((o) => (o instanceof HTMLOptionElement ? o.value : "")));
|
|
3315
3384
|
if (allValues.length === 0) {
|
|
3316
3385
|
throw new Error(`<select> ${activeSelector} has no selectable option`);
|
|
3317
3386
|
}
|
|
@@ -3325,9 +3394,7 @@ export class BrowserController {
|
|
|
3325
3394
|
// Returns either a matched value (may be "") or null when no
|
|
3326
3395
|
// option's text matches. Wrap in an object so we can
|
|
3327
3396
|
// distinguish "matched to empty value" from "no match".
|
|
3328
|
-
const matched = await
|
|
3329
|
-
.locator(`${activeSelector} option`)
|
|
3330
|
-
.evaluateAll((opts, needle) => {
|
|
3397
|
+
const matched = await optionLocator.evaluateAll((opts, needle) => {
|
|
3331
3398
|
const hit = opts
|
|
3332
3399
|
.filter((o) => o instanceof HTMLOptionElement)
|
|
3333
3400
|
.find((o) => o.textContent?.toLowerCase().includes(needle));
|
|
@@ -3341,8 +3408,8 @@ export class BrowserController {
|
|
|
3341
3408
|
if (chosenValue === undefined) {
|
|
3342
3409
|
throw new Error(`<select> ${activeSelector} has no selectable option`);
|
|
3343
3410
|
}
|
|
3344
|
-
await
|
|
3345
|
-
const committedValue = await
|
|
3411
|
+
await selectLocator.selectOption(chosenValue);
|
|
3412
|
+
const committedValue = await selectLocator.inputValue();
|
|
3346
3413
|
if (committedValue !== chosenValue) {
|
|
3347
3414
|
throw new Error(`<select> ${activeSelector}: selected value ${JSON.stringify(chosenValue)} did not stick`);
|
|
3348
3415
|
}
|
|
@@ -3352,19 +3419,19 @@ export class BrowserController {
|
|
|
3352
3419
|
// "No workspace") keeps tripping the warning every round, and
|
|
3353
3420
|
// the planner gets stuck in a select→select→… loop trying to
|
|
3354
3421
|
// satisfy a warning the form has already satisfied.
|
|
3355
|
-
await
|
|
3356
|
-
.locator(activeSelector)
|
|
3357
|
-
.first()
|
|
3422
|
+
await selectLocator
|
|
3358
3423
|
.evaluate((el) => {
|
|
3359
3424
|
if (el instanceof HTMLElement)
|
|
3360
3425
|
el.setAttribute("data-ts-touched", "1");
|
|
3361
3426
|
})
|
|
3362
3427
|
.catch(() => { });
|
|
3363
|
-
return
|
|
3428
|
+
return await selectLocator.evaluate((select) => select instanceof HTMLSelectElement
|
|
3429
|
+
? (select.selectedOptions[0]?.textContent ?? "").replace(/\s+/g, " ").trim()
|
|
3430
|
+
: "");
|
|
3364
3431
|
}
|
|
3365
3432
|
// Custom combobox path. Sentry, Radix, Headless UI, React Aria
|
|
3366
3433
|
// — every modern React picker emits role=option on its items.
|
|
3367
|
-
await this.selectFromCombobox(activeSelector, optionMatcher);
|
|
3434
|
+
return await this.selectFromCombobox(activeSelector, optionMatcher);
|
|
3368
3435
|
}
|
|
3369
3436
|
// Set the country on a phone-number field backed by a phone-local native
|
|
3370
3437
|
// <select>, including react-phone-number-input's opacity:0 country select.
|
|
@@ -3380,11 +3447,50 @@ export class BrowserController {
|
|
|
3380
3447
|
throw new Error("setPhoneCountry: empty country argument");
|
|
3381
3448
|
}
|
|
3382
3449
|
await this.clearPhoneCountryMarkers();
|
|
3450
|
+
await this.page
|
|
3451
|
+
.locator('[data-ts-phone-country-control="1"]')
|
|
3452
|
+
.evaluateAll((elements) => {
|
|
3453
|
+
elements.forEach((element) => element.removeAttribute("data-ts-phone-country-control"));
|
|
3454
|
+
})
|
|
3455
|
+
.catch(() => undefined);
|
|
3383
3456
|
if (await this.trySetPhoneCountryNativeSelect(query))
|
|
3384
3457
|
return;
|
|
3385
3458
|
throw new Error("set_phone_country: no supported native phone-country <select> found " +
|
|
3386
3459
|
"(this widget family is not supported yet) — enter a valid contact number instead.");
|
|
3387
3460
|
}
|
|
3461
|
+
async verifyPhoneCountry(country) {
|
|
3462
|
+
if (!this.page)
|
|
3463
|
+
return false;
|
|
3464
|
+
const query = classifyPhoneCountryQuery(country);
|
|
3465
|
+
if (query.dialCode === undefined && query.iso2 === undefined && query.name === undefined) {
|
|
3466
|
+
return false;
|
|
3467
|
+
}
|
|
3468
|
+
const selected = await this.page.evaluate(() => {
|
|
3469
|
+
const control = document.querySelector('select[data-ts-phone-country-control="1"]');
|
|
3470
|
+
if (!(control instanceof HTMLSelectElement))
|
|
3471
|
+
return null;
|
|
3472
|
+
const option = control.selectedOptions[0];
|
|
3473
|
+
return option === undefined
|
|
3474
|
+
? null
|
|
3475
|
+
: {
|
|
3476
|
+
value: option.value,
|
|
3477
|
+
text: (option.textContent ?? "").replace(/\s+/g, " ").trim(),
|
|
3478
|
+
};
|
|
3479
|
+
});
|
|
3480
|
+
if (selected === null)
|
|
3481
|
+
return false;
|
|
3482
|
+
const option = {
|
|
3483
|
+
text: selected.text.length > 0 ? selected.text : undefined,
|
|
3484
|
+
iso2: /^[A-Za-z]{2}$/.test(selected.value) ? selected.value.toUpperCase() : undefined,
|
|
3485
|
+
dialCode: /^\+?\d{1,4}$/.test(selected.value) ? selected.value.replace(/\D/g, "") : undefined,
|
|
3486
|
+
};
|
|
3487
|
+
return phoneCountryOptionMatches(query, option);
|
|
3488
|
+
}
|
|
3489
|
+
async hasPhoneCountryControl() {
|
|
3490
|
+
if (!this.page)
|
|
3491
|
+
return false;
|
|
3492
|
+
return (await this.page.locator('select[data-ts-phone-country-control="1"]').count()) === 1;
|
|
3493
|
+
}
|
|
3388
3494
|
// Strategy 1 — a native <select> that governs the phone country (react-
|
|
3389
3495
|
// phone-number-input's `opacity:0` PhoneInputCountrySelect, or any bespoke
|
|
3390
3496
|
// widget backed by a real <select>). Detection is deliberately conservative
|
|
@@ -3487,6 +3593,7 @@ export class BrowserController {
|
|
|
3487
3593
|
sel.value = val;
|
|
3488
3594
|
sel.dispatchEvent(new Event("input", { bubbles: true }));
|
|
3489
3595
|
sel.dispatchEvent(new Event("change", { bubbles: true }));
|
|
3596
|
+
sel.setAttribute("data-ts-phone-country-control", "1");
|
|
3490
3597
|
return true;
|
|
3491
3598
|
}, { marker: best.marker, val: value });
|
|
3492
3599
|
const committedValue = assigned
|
|
@@ -3651,7 +3758,9 @@ export class BrowserController {
|
|
|
3651
3758
|
else if ((await options.count()) === 0) {
|
|
3652
3759
|
throw new Error(`combobox ${triggerSelector}: opened popup has no actionable options`);
|
|
3653
3760
|
}
|
|
3761
|
+
const committedText = (await target.innerText()).replace(/\s+/g, " ").trim();
|
|
3654
3762
|
await this.clickComboboxOption(target);
|
|
3763
|
+
return committedText;
|
|
3655
3764
|
}
|
|
3656
3765
|
finally {
|
|
3657
3766
|
await this.clearComboboxMarkers();
|
|
@@ -5104,6 +5213,21 @@ export class BrowserController {
|
|
|
5104
5213
|
...amount,
|
|
5105
5214
|
};
|
|
5106
5215
|
}
|
|
5216
|
+
async isPayPalHostedCheckout() {
|
|
5217
|
+
if (!this.page)
|
|
5218
|
+
throw new Error("Browser not started");
|
|
5219
|
+
const iframeDescriptors = await this.page.evaluate(() => Array.from(document.querySelectorAll("iframe"), (iframe) => ({
|
|
5220
|
+
url: iframe.src,
|
|
5221
|
+
name: iframe.name,
|
|
5222
|
+
title: iframe.title,
|
|
5223
|
+
})));
|
|
5224
|
+
const frameDescriptors = this.page.frames().map((frame) => ({
|
|
5225
|
+
url: frame.url(),
|
|
5226
|
+
name: frame.name(),
|
|
5227
|
+
title: "",
|
|
5228
|
+
}));
|
|
5229
|
+
return hasPayPalHostedCheckoutFrame([...iframeDescriptors, ...frameDescriptors]);
|
|
5230
|
+
}
|
|
5107
5231
|
// Common autocomplete/name selectors across all CDP-reachable frames,
|
|
5108
5232
|
// including cross-origin hosted fields. No PSP-specific adapters.
|
|
5109
5233
|
async fillAndSubmitCheckout(card) {
|
|
@@ -5111,7 +5235,7 @@ export class BrowserController {
|
|
|
5111
5235
|
throw new Error("Browser not started");
|
|
5112
5236
|
const frames = this.page.frames();
|
|
5113
5237
|
const filled = new Set();
|
|
5114
|
-
const fillFirst = async (field, value, selectors) => {
|
|
5238
|
+
const fillFirst = async (field, value, selectors, typePerKey = false) => {
|
|
5115
5239
|
if (value === undefined || value.length === 0)
|
|
5116
5240
|
return false;
|
|
5117
5241
|
for (const frame of frames) {
|
|
@@ -5137,6 +5261,10 @@ export class BrowserController {
|
|
|
5137
5261
|
if (!selected)
|
|
5138
5262
|
continue;
|
|
5139
5263
|
}
|
|
5264
|
+
else if (typePerKey) {
|
|
5265
|
+
await input.fill("");
|
|
5266
|
+
await input.pressSequentially(value, { delay: this.humanize ? rand(40, 110) : 0 });
|
|
5267
|
+
}
|
|
5140
5268
|
else {
|
|
5141
5269
|
await input.fill(value);
|
|
5142
5270
|
}
|
|
@@ -5146,13 +5274,38 @@ export class BrowserController {
|
|
|
5146
5274
|
}
|
|
5147
5275
|
return false;
|
|
5148
5276
|
};
|
|
5277
|
+
const hasFillable = async (selectors) => {
|
|
5278
|
+
for (const frame of frames) {
|
|
5279
|
+
const matches = frame.locator(selectors);
|
|
5280
|
+
const count = Math.min(await matches.count().catch(() => 0), 10);
|
|
5281
|
+
for (let i = 0; i < count; i += 1) {
|
|
5282
|
+
const input = matches.nth(i);
|
|
5283
|
+
if (!(await input.isVisible().catch(() => false)))
|
|
5284
|
+
continue;
|
|
5285
|
+
if (await input.isEnabled().catch(() => false))
|
|
5286
|
+
return true;
|
|
5287
|
+
}
|
|
5288
|
+
}
|
|
5289
|
+
return false;
|
|
5290
|
+
};
|
|
5149
5291
|
try {
|
|
5150
5292
|
await fillFirst("pan", card.pan, 'input[autocomplete~="cc-number"],input[name*="cardnumber" i],input[id*="card-number" i],input[id*="cardnumber" i]');
|
|
5151
|
-
const
|
|
5152
|
-
const
|
|
5153
|
-
|
|
5154
|
-
|
|
5155
|
-
await fillFirst("
|
|
5293
|
+
const expiryMonthSelectors = '[autocomplete~="cc-exp-month"],[name*="exp_month" i],[name*="expmonth" i],[name*="exp" i][name*="month" i],[id*="exp" i][id*="month" i]';
|
|
5294
|
+
const expiryYearSelectors = '[autocomplete~="cc-exp-year"],[name*="exp_year" i],[name*="expyear" i],[name*="exp" i][name*="year" i],[id*="exp" i][id*="year" i]';
|
|
5295
|
+
const hasSplitExpiry = (await hasFillable(expiryMonthSelectors)) && (await hasFillable(expiryYearSelectors));
|
|
5296
|
+
if (hasSplitExpiry) {
|
|
5297
|
+
await fillFirst("exp_month", card.exp_month.padStart(2, "0"), expiryMonthSelectors);
|
|
5298
|
+
await fillFirst("exp_year", card.exp_year, expiryYearSelectors);
|
|
5299
|
+
}
|
|
5300
|
+
else {
|
|
5301
|
+
// A combined expiry field's formatter owns the slash. Send only the four
|
|
5302
|
+
// digits as real key events so numeric-only fields accept them and the
|
|
5303
|
+
// site's key/input handlers can turn e.g. "1230" into "12/30".
|
|
5304
|
+
const combined = await fillFirst("expiry", `${card.exp_month.padStart(2, "0")}${card.exp_year.slice(-2)}`, '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],input[placeholder="MM/YY" i],input[placeholder="MM / YY" i],input[aria-label="MM/YY" i],input[aria-label="MM / YY" i],label:has-text("MM/YY") input,label:has-text("MM / YY") input', true);
|
|
5305
|
+
if (!combined) {
|
|
5306
|
+
await fillFirst("exp_month", card.exp_month.padStart(2, "0"), expiryMonthSelectors);
|
|
5307
|
+
await fillFirst("exp_year", card.exp_year, expiryYearSelectors);
|
|
5308
|
+
}
|
|
5156
5309
|
}
|
|
5157
5310
|
const fields = [
|
|
5158
5311
|
[
|
|
@@ -8266,6 +8419,47 @@ export class BrowserController {
|
|
|
8266
8419
|
// best-effort
|
|
8267
8420
|
}
|
|
8268
8421
|
}
|
|
8422
|
+
// End one sequential task without tearing down Chrome. The original,
|
|
8423
|
+
// handler-bound page survives; OAuth popups and any other incidental tabs are
|
|
8424
|
+
// closed. about:blank replaces the task document while the persistent profile
|
|
8425
|
+
// (cookies, OAuth identity, local profile data) intentionally remains intact.
|
|
8426
|
+
async resetPageForReuse() {
|
|
8427
|
+
const context = this.context;
|
|
8428
|
+
const primaryPage = this.primaryPage;
|
|
8429
|
+
if (!this.isConnected() || context === null || primaryPage === null || primaryPage.isClosed()) {
|
|
8430
|
+
throw new Error("warm browser page is not reusable");
|
|
8431
|
+
}
|
|
8432
|
+
this.page = primaryPage;
|
|
8433
|
+
this.oauthProductPage = null;
|
|
8434
|
+
this.oauthNetLog = [];
|
|
8435
|
+
this.mouseX = 100;
|
|
8436
|
+
this.mouseY = 100;
|
|
8437
|
+
for (const page of context.pages()) {
|
|
8438
|
+
if (page !== primaryPage && !page.isClosed()) {
|
|
8439
|
+
let timer;
|
|
8440
|
+
try {
|
|
8441
|
+
await Promise.race([
|
|
8442
|
+
page.close(),
|
|
8443
|
+
new Promise((_, reject) => {
|
|
8444
|
+
timer = setTimeout(() => reject(new Error("timed out closing warm browser popup")), 5_000);
|
|
8445
|
+
timer.unref();
|
|
8446
|
+
}),
|
|
8447
|
+
]);
|
|
8448
|
+
}
|
|
8449
|
+
finally {
|
|
8450
|
+
if (timer !== undefined)
|
|
8451
|
+
clearTimeout(timer);
|
|
8452
|
+
}
|
|
8453
|
+
}
|
|
8454
|
+
}
|
|
8455
|
+
if (context.pages().some((page) => page !== primaryPage && !page.isClosed())) {
|
|
8456
|
+
throw new Error("warm browser reset left a non-primary page open");
|
|
8457
|
+
}
|
|
8458
|
+
await primaryPage.goto("about:blank", {
|
|
8459
|
+
waitUntil: "domcontentloaded",
|
|
8460
|
+
timeout: 30_000,
|
|
8461
|
+
});
|
|
8462
|
+
}
|
|
8269
8463
|
async close() {
|
|
8270
8464
|
// Each step is best-effort and independent: a throw closing the page
|
|
8271
8465
|
// or context must NOT skip the Xvfb teardown below, or the virtual
|
|
@@ -8286,6 +8480,10 @@ export class BrowserController {
|
|
|
8286
8480
|
]);
|
|
8287
8481
|
if (this.page)
|
|
8288
8482
|
await capped(this.page.close(), 5_000);
|
|
8483
|
+
this.page = null;
|
|
8484
|
+
this.primaryPage = null;
|
|
8485
|
+
this.oauthProductPage = null;
|
|
8486
|
+
this.oauthNetLog = [];
|
|
8289
8487
|
if (this.context)
|
|
8290
8488
|
await capped(this.context.close(), 10_000);
|
|
8291
8489
|
// Self-launch path: disconnect the CDP browser and SIGKILL the Chrome we
|