iframer-toolkit 0.10.5 → 0.10.8
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 +165 -65
- package/dist/cli.js +775 -78
- package/dist/local-server.cjs +948 -303
- package/dist/mcp-server.cjs +35 -5
- package/extension/manifest.json +1 -1
- package/package.json +1 -1
- package/skills/iframer.md +65 -10
package/dist/cli.js
CHANGED
|
@@ -902,6 +902,54 @@ function findChromeExecutable() {
|
|
|
902
902
|
if (import_fs3.default.existsSync("/usr/bin/google-chrome-stable")) return "/usr/bin/google-chrome-stable";
|
|
903
903
|
return void 0;
|
|
904
904
|
}
|
|
905
|
+
function armHeadedIdleTimer() {
|
|
906
|
+
if (headedIdleTimer) clearTimeout(headedIdleTimer);
|
|
907
|
+
headedIdleTimer = setTimeout(() => {
|
|
908
|
+
if (!headedBrowser) return;
|
|
909
|
+
const pages = headedBrowser.contexts().reduce((n, c) => n + c.pages().length, 0);
|
|
910
|
+
if (pages > 0) {
|
|
911
|
+
armHeadedIdleTimer();
|
|
912
|
+
return;
|
|
913
|
+
}
|
|
914
|
+
log2.info("idle timeout for the pivot headed browser, closing");
|
|
915
|
+
void closeHeadedBrowser();
|
|
916
|
+
}, HEADED_IDLE_MS);
|
|
917
|
+
}
|
|
918
|
+
async function getHeadedBrowser() {
|
|
919
|
+
if (!headedBrowser?.isConnected()) {
|
|
920
|
+
const marker = `--iframer-key=pivot-headed-${process.pid}-${Date.now()}`;
|
|
921
|
+
headedBrowser = await import_patchright.chromium.launch({ headless: false, args: [...STEALTH_ARGS, marker] });
|
|
922
|
+
headedPid = findChromePidByMarker(marker);
|
|
923
|
+
if (headedPid) {
|
|
924
|
+
registerBrowser({
|
|
925
|
+
key: "pivot::headed",
|
|
926
|
+
chromePid: headedPid,
|
|
927
|
+
ownerPid: process.pid,
|
|
928
|
+
marker,
|
|
929
|
+
launchedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
930
|
+
});
|
|
931
|
+
} else {
|
|
932
|
+
log2.warn("could not resolve Chrome PID for the pivot headed browser \u2014 force-kill unavailable");
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
armHeadedIdleTimer();
|
|
936
|
+
return headedBrowser;
|
|
937
|
+
}
|
|
938
|
+
async function closeHeadedBrowser() {
|
|
939
|
+
if (headedIdleTimer) {
|
|
940
|
+
clearTimeout(headedIdleTimer);
|
|
941
|
+
headedIdleTimer = null;
|
|
942
|
+
}
|
|
943
|
+
if (headedBrowser) {
|
|
944
|
+
await headedBrowser.close().catch(() => {
|
|
945
|
+
});
|
|
946
|
+
headedBrowser = null;
|
|
947
|
+
}
|
|
948
|
+
if (headedPid !== null) {
|
|
949
|
+
unregisterBrowser(headedPid);
|
|
950
|
+
headedPid = null;
|
|
951
|
+
}
|
|
952
|
+
}
|
|
905
953
|
async function getBrowser(_name = "chromium") {
|
|
906
954
|
if (cachedBrowser) {
|
|
907
955
|
if (cachedBrowser.isConnected()) return cachedBrowser;
|
|
@@ -919,6 +967,7 @@ async function getBrowser(_name = "chromium") {
|
|
|
919
967
|
return cachedBrowser;
|
|
920
968
|
}
|
|
921
969
|
async function closeBrowser() {
|
|
970
|
+
await closeHeadedBrowser();
|
|
922
971
|
if (!cachedBrowser) return;
|
|
923
972
|
try {
|
|
924
973
|
await cachedBrowser.close();
|
|
@@ -955,7 +1004,7 @@ async function launchHeadful(displayNum) {
|
|
|
955
1004
|
log2.debug(`headful: ${executablePath || "patchright chromium"}, extensions: ${hasExtensions}`);
|
|
956
1005
|
return import_patchright.chromium.launch(launchOpts);
|
|
957
1006
|
}
|
|
958
|
-
var import_fs3, import_patchright, log2, UBLOCK_PATH, cachedBrowser;
|
|
1007
|
+
var import_fs3, import_patchright, log2, UBLOCK_PATH, cachedBrowser, headedBrowser, headedPid, headedIdleTimer, HEADED_IDLE_MS;
|
|
959
1008
|
var init_launcher = __esm({
|
|
960
1009
|
"src/lib/browser/launcher.ts"() {
|
|
961
1010
|
"use strict";
|
|
@@ -963,9 +1012,14 @@ var init_launcher = __esm({
|
|
|
963
1012
|
import_patchright = require("patchright");
|
|
964
1013
|
init_stealth();
|
|
965
1014
|
init_logger();
|
|
1015
|
+
init_registry();
|
|
966
1016
|
log2 = createLogger("launcher");
|
|
967
1017
|
UBLOCK_PATH = "/extensions/uBlock0.chromium";
|
|
968
1018
|
cachedBrowser = null;
|
|
1019
|
+
headedBrowser = null;
|
|
1020
|
+
headedPid = null;
|
|
1021
|
+
headedIdleTimer = null;
|
|
1022
|
+
HEADED_IDLE_MS = 5 * 60 * 1e3;
|
|
969
1023
|
}
|
|
970
1024
|
});
|
|
971
1025
|
|
|
@@ -1023,7 +1077,13 @@ var init_constants = __esm({
|
|
|
1023
1077
|
/** Scroll step delay */
|
|
1024
1078
|
SCROLL_DELAY: 150,
|
|
1025
1079
|
/** Stale state check interval */
|
|
1026
|
-
STALE_CHECK_INTERVAL: 2e3
|
|
1080
|
+
STALE_CHECK_INTERVAL: 2e3,
|
|
1081
|
+
/** captureApi: grace period after the last step for a submit's XHR to fire */
|
|
1082
|
+
CAPTURE_SETTLE: 300,
|
|
1083
|
+
/** captureApi: max wait for in-flight requests to answer before stopping */
|
|
1084
|
+
CAPTURE_PENDING_MAX: 3e3,
|
|
1085
|
+
/** Post-failure probe ("does the selector exist right now?") budget */
|
|
1086
|
+
FAILURE_PROBE: 3e3
|
|
1027
1087
|
};
|
|
1028
1088
|
CAPTCHA_GRID = {
|
|
1029
1089
|
/** Pixels from bframe top to grid start (reCAPTCHA) */
|
|
@@ -1061,6 +1121,10 @@ var init_constants = __esm({
|
|
|
1061
1121
|
DEFAULT_STALE: 2e4,
|
|
1062
1122
|
NAVIGATION: 6e4,
|
|
1063
1123
|
SELECTOR_WAIT: 1e4,
|
|
1124
|
+
// Actionability wait for click. Under the 20s stale timer on purpose, so
|
|
1125
|
+
// Playwright's own reason (not visible, covered by another element…) reaches
|
|
1126
|
+
// the agent instead of a generic "no state change".
|
|
1127
|
+
CLICK: 8e3,
|
|
1064
1128
|
TOTP_INPUT: 5e3,
|
|
1065
1129
|
API_REQUEST: 18e4,
|
|
1066
1130
|
HEALTH_CHECK: 3e3,
|
|
@@ -1817,7 +1881,7 @@ var init_cloak_browser = __esm({
|
|
|
1817
1881
|
function keyOf(mode, instanceId) {
|
|
1818
1882
|
return `${mode}::${instanceId}`;
|
|
1819
1883
|
}
|
|
1820
|
-
var import_patchright2, import_crypto2, log7, DEFAULT_IDLE_TIMEOUT, CLOSE_GRACE_MS, DEFAULT_INSTANCE, sleep3, BrowserDaemon;
|
|
1884
|
+
var import_patchright2, import_crypto2, log7, DEFAULT_IDLE_TIMEOUT, CLOSE_GRACE_MS, BUSY_MAX_MS, BLANK_IDLE_MS, DEFAULT_INSTANCE, sleep3, BrowserDaemon;
|
|
1821
1885
|
var init_daemon = __esm({
|
|
1822
1886
|
"src/lib/browser/daemon.ts"() {
|
|
1823
1887
|
"use strict";
|
|
@@ -1830,6 +1894,8 @@ var init_daemon = __esm({
|
|
|
1830
1894
|
log7 = createLogger("daemon");
|
|
1831
1895
|
DEFAULT_IDLE_TIMEOUT = 5 * 60 * 1e3;
|
|
1832
1896
|
CLOSE_GRACE_MS = 5e3;
|
|
1897
|
+
BUSY_MAX_MS = 30 * 60 * 1e3;
|
|
1898
|
+
BLANK_IDLE_MS = 2 * 60 * 1e3;
|
|
1833
1899
|
DEFAULT_INSTANCE = "default";
|
|
1834
1900
|
sleep3 = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
1835
1901
|
BrowserDaemon = class {
|
|
@@ -1918,7 +1984,8 @@ var init_daemon = __esm({
|
|
|
1918
1984
|
createdAt: /* @__PURE__ */ new Date(),
|
|
1919
1985
|
chromePid,
|
|
1920
1986
|
marker,
|
|
1921
|
-
active: 0
|
|
1987
|
+
active: 0,
|
|
1988
|
+
busySince: null
|
|
1922
1989
|
};
|
|
1923
1990
|
this.instances.set(key, instance);
|
|
1924
1991
|
this.resetIdleTimer(key);
|
|
@@ -1930,14 +1997,19 @@ var init_daemon = __esm({
|
|
|
1930
1997
|
* Always pair with release() in a finally block. */
|
|
1931
1998
|
acquire(mode, instanceId = DEFAULT_INSTANCE) {
|
|
1932
1999
|
const instance = this.instances.get(keyOf(mode, instanceId));
|
|
1933
|
-
if (instance)
|
|
2000
|
+
if (!instance) return;
|
|
2001
|
+
if (instance.active === 0) instance.busySince = Date.now();
|
|
2002
|
+
instance.active++;
|
|
1934
2003
|
}
|
|
1935
2004
|
release(mode, instanceId = DEFAULT_INSTANCE) {
|
|
1936
2005
|
const key = keyOf(mode, instanceId);
|
|
1937
2006
|
const instance = this.instances.get(key);
|
|
1938
2007
|
if (!instance) return;
|
|
1939
2008
|
instance.active = Math.max(0, instance.active - 1);
|
|
1940
|
-
if (instance.active === 0)
|
|
2009
|
+
if (instance.active === 0) {
|
|
2010
|
+
instance.busySince = null;
|
|
2011
|
+
this.resetIdleTimer(key);
|
|
2012
|
+
}
|
|
1941
2013
|
}
|
|
1942
2014
|
isRunning(mode, instanceId = DEFAULT_INSTANCE) {
|
|
1943
2015
|
const instance = this.instances.get(keyOf(mode, instanceId));
|
|
@@ -1948,6 +2020,35 @@ var init_daemon = __esm({
|
|
|
1948
2020
|
return false;
|
|
1949
2021
|
}
|
|
1950
2022
|
}
|
|
2023
|
+
/** Close instances that never left about:blank and have been idle a while.
|
|
2024
|
+
* Those are accidents — a pipeline that failed before its first navigate, or
|
|
2025
|
+
* a mode picked for a call that never needed a browser — and in headful mode
|
|
2026
|
+
* each one is an empty window sitting on the user's desktop. Called on the
|
|
2027
|
+
* server's reap tick, so it also catches instances whose idle timer was lost
|
|
2028
|
+
* (e.g. the daemon was restarted under them). */
|
|
2029
|
+
sweepBlankInstances() {
|
|
2030
|
+
const stopped = [];
|
|
2031
|
+
const now = Date.now();
|
|
2032
|
+
for (const [key, inst] of this.instances.entries()) {
|
|
2033
|
+
if (inst.active > 0) continue;
|
|
2034
|
+
if (now - inst.createdAt.getTime() < BLANK_IDLE_MS) continue;
|
|
2035
|
+
let blank = false;
|
|
2036
|
+
try {
|
|
2037
|
+
const pages = inst.context.pages();
|
|
2038
|
+
blank = pages.length === 0 || pages.every((p) => {
|
|
2039
|
+
const u = p.url();
|
|
2040
|
+
return u === "about:blank" || u === "" || u === "chrome://newtab/";
|
|
2041
|
+
});
|
|
2042
|
+
} catch {
|
|
2043
|
+
continue;
|
|
2044
|
+
}
|
|
2045
|
+
if (!blank) continue;
|
|
2046
|
+
log7.info(`blank-orphan sweep: ${key} never left about:blank, stopping`);
|
|
2047
|
+
stopped.push(key);
|
|
2048
|
+
this.stopKey(key).catch((err) => log7.warn(`blank sweep stop failed for ${key}: ${err}`));
|
|
2049
|
+
}
|
|
2050
|
+
return stopped;
|
|
2051
|
+
}
|
|
1951
2052
|
/** Distinct modes that currently have at least one live instance. */
|
|
1952
2053
|
runningModes() {
|
|
1953
2054
|
return [...new Set(this.liveInstances().map((i) => i.mode))];
|
|
@@ -2066,8 +2167,14 @@ var init_daemon = __esm({
|
|
|
2066
2167
|
setTimeout(() => {
|
|
2067
2168
|
const instance = this.instances.get(key);
|
|
2068
2169
|
if (instance && instance.active > 0) {
|
|
2069
|
-
|
|
2070
|
-
|
|
2170
|
+
const busyMs = instance.busySince ? Date.now() - instance.busySince : 0;
|
|
2171
|
+
if (busyMs < BUSY_MAX_MS) {
|
|
2172
|
+
this.resetIdleTimer(key);
|
|
2173
|
+
return;
|
|
2174
|
+
}
|
|
2175
|
+
log7.warn(`${key} reported busy for ${Math.round(busyMs / 6e4)}m \u2014 treating active=${instance.active} as leaked, stopping`);
|
|
2176
|
+
instance.active = 0;
|
|
2177
|
+
instance.busySince = null;
|
|
2071
2178
|
}
|
|
2072
2179
|
log7.info(`Idle timeout for ${key}, stopping...`);
|
|
2073
2180
|
this.stopKey(key).catch((err) => log7.warn(`idle stop failed for ${key}: ${err}`));
|
|
@@ -2273,6 +2380,59 @@ var init_ref_store = __esm({
|
|
|
2273
2380
|
}
|
|
2274
2381
|
});
|
|
2275
2382
|
|
|
2383
|
+
// src/lib/site-protection.ts
|
|
2384
|
+
async function paceProtectedDomain(domain) {
|
|
2385
|
+
const now = Date.now();
|
|
2386
|
+
const slot = Math.max(now, nextRequest.get(domain) || 0);
|
|
2387
|
+
nextRequest.set(domain, slot + PROTECTED_INTERVAL_MS);
|
|
2388
|
+
if (slot > now) await new Promise((resolve) => setTimeout(resolve, slot - now));
|
|
2389
|
+
}
|
|
2390
|
+
async function detectSiteProtection(page) {
|
|
2391
|
+
const cookies = await page.context().cookies(page.url()).catch(() => []);
|
|
2392
|
+
if (cookies.some((c) => /^(incap_ses_|visid_incap_|nlbi_)/i.test(c.name))) return "imperva";
|
|
2393
|
+
if (cookies.some((c) => /^(?:_abck|bm_sz|ak_bmsc|bm_sv)$/.test(c.name))) return "akamai";
|
|
2394
|
+
const html = await page.content().catch(() => "");
|
|
2395
|
+
if (/\/[_]Incapsula_Resource|incapsula incident id|powered by imperva/i.test(html)) return "imperva";
|
|
2396
|
+
if (/errors\.edgesuite\.net|akamai bot manager/i.test(html)) return "akamai";
|
|
2397
|
+
return null;
|
|
2398
|
+
}
|
|
2399
|
+
function protectionHooks(headless, respectful = false) {
|
|
2400
|
+
return {
|
|
2401
|
+
before: async (page, step) => {
|
|
2402
|
+
const protection = await detectSiteProtection(page);
|
|
2403
|
+
if (protection) {
|
|
2404
|
+
respectful = true;
|
|
2405
|
+
if (headless) throw new HeadedRequiredError(page.url());
|
|
2406
|
+
}
|
|
2407
|
+
if (respectful) await paceProtectedDomain(new URL(step.url || page.url()).hostname);
|
|
2408
|
+
},
|
|
2409
|
+
after: async (page) => {
|
|
2410
|
+
const protection = await detectSiteProtection(page);
|
|
2411
|
+
if (!protection) return;
|
|
2412
|
+
respectful = true;
|
|
2413
|
+
if (headless) throw new HeadedRequiredError(page.url());
|
|
2414
|
+
}
|
|
2415
|
+
};
|
|
2416
|
+
}
|
|
2417
|
+
var PROTECTED_INTERVAL_MS, nextRequest, HeadedRequiredError;
|
|
2418
|
+
var init_site_protection = __esm({
|
|
2419
|
+
"src/lib/site-protection.ts"() {
|
|
2420
|
+
"use strict";
|
|
2421
|
+
PROTECTED_INTERVAL_MS = 3e3;
|
|
2422
|
+
nextRequest = /* @__PURE__ */ new Map();
|
|
2423
|
+
HeadedRequiredError = class extends Error {
|
|
2424
|
+
constructor(url) {
|
|
2425
|
+
super("This site requires a headed browser.");
|
|
2426
|
+
this.url = url;
|
|
2427
|
+
}
|
|
2428
|
+
url;
|
|
2429
|
+
stepIndex = 0;
|
|
2430
|
+
results = [];
|
|
2431
|
+
obstacles = [];
|
|
2432
|
+
};
|
|
2433
|
+
}
|
|
2434
|
+
});
|
|
2435
|
+
|
|
2276
2436
|
// src/lib/actions/types.ts
|
|
2277
2437
|
function failedStepResult(step, error, durationMs, stepIndex = -1) {
|
|
2278
2438
|
return { stepIndex, step, ok: false, error, durationMs };
|
|
@@ -2416,6 +2576,24 @@ async function humanType(page, selector, text, opts = {}) {
|
|
|
2416
2576
|
await sleep4(randRange(TIMING.POST_CLICK));
|
|
2417
2577
|
}
|
|
2418
2578
|
await assertFocused(page, selector, !opts.skipClick);
|
|
2579
|
+
if (opts.clear) {
|
|
2580
|
+
const hadText = await page.evaluate(() => {
|
|
2581
|
+
const el = document.activeElement;
|
|
2582
|
+
if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {
|
|
2583
|
+
el.select();
|
|
2584
|
+
return el.value.length > 0;
|
|
2585
|
+
}
|
|
2586
|
+
if (el?.isContentEditable) {
|
|
2587
|
+
window.getSelection()?.selectAllChildren(el);
|
|
2588
|
+
return (el.textContent || "").length > 0;
|
|
2589
|
+
}
|
|
2590
|
+
return false;
|
|
2591
|
+
});
|
|
2592
|
+
if (hadText) {
|
|
2593
|
+
await page.keyboard.press("Backspace");
|
|
2594
|
+
await sleep4(randRange(TIMING.CHAR_DELAY));
|
|
2595
|
+
}
|
|
2596
|
+
}
|
|
2419
2597
|
const factor = SPEED_FACTOR[opts.speed || "normal"] ?? 1;
|
|
2420
2598
|
for (const char of text) {
|
|
2421
2599
|
await page.keyboard.type(char);
|
|
@@ -2577,7 +2755,41 @@ var init_humanize = __esm({
|
|
|
2577
2755
|
}
|
|
2578
2756
|
});
|
|
2579
2757
|
|
|
2758
|
+
// src/lib/budget.ts
|
|
2759
|
+
function withBudget(p, ms, label = "page call") {
|
|
2760
|
+
p.catch(() => void 0);
|
|
2761
|
+
let timer;
|
|
2762
|
+
return Promise.race([
|
|
2763
|
+
p,
|
|
2764
|
+
new Promise((_, reject) => {
|
|
2765
|
+
timer = setTimeout(() => reject(new BudgetExceededError(label, ms)), ms);
|
|
2766
|
+
timer.unref?.();
|
|
2767
|
+
})
|
|
2768
|
+
]).finally(() => {
|
|
2769
|
+
if (timer) clearTimeout(timer);
|
|
2770
|
+
});
|
|
2771
|
+
}
|
|
2772
|
+
var BudgetExceededError;
|
|
2773
|
+
var init_budget = __esm({
|
|
2774
|
+
"src/lib/budget.ts"() {
|
|
2775
|
+
"use strict";
|
|
2776
|
+
BudgetExceededError = class extends Error {
|
|
2777
|
+
constructor(label, ms) {
|
|
2778
|
+
super(`${label} did not answer within ${ms}ms`);
|
|
2779
|
+
this.name = "BudgetExceededError";
|
|
2780
|
+
}
|
|
2781
|
+
};
|
|
2782
|
+
}
|
|
2783
|
+
});
|
|
2784
|
+
|
|
2580
2785
|
// src/lib/actions/resolve-selector.ts
|
|
2786
|
+
function assertCssSelector(selector) {
|
|
2787
|
+
if (PLAYWRIGHT_ONLY.test(selector)) {
|
|
2788
|
+
throw new Error(
|
|
2789
|
+
`Unsupported selector "${selector}": Playwright text/role selectors (text=, :has-text(), :text-is(), >>) aren't supported. Locate the element by its text with a \`find\` step (e.g. { type: "find", text: "Texas" } or { role: "option", name: "Texas" }) and act on the @e ref it returns, in the same execute call.`
|
|
2790
|
+
);
|
|
2791
|
+
}
|
|
2792
|
+
}
|
|
2581
2793
|
function resolveSelector(selector, ctx) {
|
|
2582
2794
|
if (selector.startsWith("@a:")) {
|
|
2583
2795
|
const name = selector.slice(3);
|
|
@@ -2598,23 +2810,160 @@ function resolveSelector(selector, ctx) {
|
|
|
2598
2810
|
}
|
|
2599
2811
|
return ref.selector;
|
|
2600
2812
|
}
|
|
2813
|
+
assertCssSelector(selector);
|
|
2601
2814
|
return selector;
|
|
2602
2815
|
}
|
|
2603
2816
|
function anchorNameOf(selector) {
|
|
2604
2817
|
return typeof selector === "string" && selector.startsWith("@a:") ? selector.slice(3) : null;
|
|
2605
2818
|
}
|
|
2819
|
+
var PLAYWRIGHT_ONLY;
|
|
2606
2820
|
var init_resolve_selector = __esm({
|
|
2607
2821
|
"src/lib/actions/resolve-selector.ts"() {
|
|
2608
2822
|
"use strict";
|
|
2823
|
+
PLAYWRIGHT_ONLY = /^\s*(text|xpath|role|id|data-testid|internal:[\w-]+)\s*=|:(has-text|text|text-is|text-matches|visible|nth-match|right-of|left-of|above|below|near)\b|\s>>\s/;
|
|
2824
|
+
}
|
|
2825
|
+
});
|
|
2826
|
+
|
|
2827
|
+
// src/lib/reachability.ts
|
|
2828
|
+
function isHoneypotReason(reason) {
|
|
2829
|
+
return !!reason && HONEYPOT_REASONS.has(reason);
|
|
2830
|
+
}
|
|
2831
|
+
function looksLikeBaitField(attrs) {
|
|
2832
|
+
const name = attrs.name || "";
|
|
2833
|
+
const id = attrs.id || "";
|
|
2834
|
+
if (HONEYPOT_NAME_RE.test(name) || HONEYPOT_NAME_RE.test(id)) return true;
|
|
2835
|
+
const textual = !attrs.type || /^(text|email|url|tel|search|number)$/i.test(attrs.type);
|
|
2836
|
+
if (textual && attrs.tabindex === "-1") return true;
|
|
2837
|
+
return false;
|
|
2838
|
+
}
|
|
2839
|
+
function honeypotRefusalMessage(selector, reason, name) {
|
|
2840
|
+
const what = name ? `"${name}" (${selector})` : selector;
|
|
2841
|
+
return `fill: refusing to fill ${what} \u2014 no human can reach this field (${reason}). It is almost certainly a honeypot: filling it marks the submission as a bot and the form fails with a generic error. Skip it. If you are certain it is a real field, retry with force: true.`;
|
|
2842
|
+
}
|
|
2843
|
+
async function probeField(page, selector) {
|
|
2844
|
+
const r = await page.evaluate((sel) => {
|
|
2845
|
+
const el = document.querySelector(sel);
|
|
2846
|
+
if (!el) return { found: false };
|
|
2847
|
+
const attrs = {
|
|
2848
|
+
name: el.getAttribute("name"),
|
|
2849
|
+
id: el.id || null,
|
|
2850
|
+
tabindex: el.getAttribute("tabindex"),
|
|
2851
|
+
type: el.getAttribute("type")
|
|
2852
|
+
};
|
|
2853
|
+
const tag = el.tagName.toLowerCase();
|
|
2854
|
+
{
|
|
2855
|
+
const r0 = el.getBoundingClientRect();
|
|
2856
|
+
const cx0 = r0.left + r0.width / 2, cy0 = r0.top + r0.height / 2;
|
|
2857
|
+
const dx = cx0 < 0 || cx0 >= window.innerWidth ? cx0 - window.innerWidth / 2 : 0;
|
|
2858
|
+
const dy = cy0 < 0 || cy0 >= window.innerHeight ? cy0 - window.innerHeight / 2 : 0;
|
|
2859
|
+
if (dx || dy) try {
|
|
2860
|
+
window.scrollBy(dx, dy);
|
|
2861
|
+
} catch {
|
|
2862
|
+
}
|
|
2863
|
+
}
|
|
2864
|
+
const rect = el.getBoundingClientRect();
|
|
2865
|
+
let reason = null;
|
|
2866
|
+
if (rect.width === 0 || rect.height === 0) reason = "zero-size";
|
|
2867
|
+
const style = getComputedStyle(el);
|
|
2868
|
+
if (!reason && (style.display === "none" || style.visibility === "hidden")) reason = "hidden";
|
|
2869
|
+
if (!reason) {
|
|
2870
|
+
let opacity = 1;
|
|
2871
|
+
for (let n = el; n && n !== document.documentElement; n = n.parentElement) {
|
|
2872
|
+
const o = parseFloat(getComputedStyle(n).opacity);
|
|
2873
|
+
const animating = o < 1 && typeof n.getAnimations === "function" && n.getAnimations().length > 0;
|
|
2874
|
+
if (!isNaN(o) && !animating) opacity *= o;
|
|
2875
|
+
}
|
|
2876
|
+
if (opacity < 0.05) reason = "transparent";
|
|
2877
|
+
}
|
|
2878
|
+
if (!reason) {
|
|
2879
|
+
const docW = Math.max(document.documentElement.scrollWidth, window.innerWidth);
|
|
2880
|
+
const docH = Math.max(document.documentElement.scrollHeight, window.innerHeight);
|
|
2881
|
+
const left = rect.left + window.scrollX, right = rect.right + window.scrollX;
|
|
2882
|
+
const top = rect.top + window.scrollY, bottom = rect.bottom + window.scrollY;
|
|
2883
|
+
if (right <= 0 || left >= docW || bottom <= 0 || top >= docH) reason = "offscreen";
|
|
2884
|
+
}
|
|
2885
|
+
if (!reason) {
|
|
2886
|
+
const cx = rect.left + rect.width / 2, cy = rect.top + rect.height / 2;
|
|
2887
|
+
const inViewport = cx >= 0 && cy >= 0 && cx < window.innerWidth && cy < window.innerHeight;
|
|
2888
|
+
let clipped = false;
|
|
2889
|
+
for (let a = el.parentElement; a && a !== document.body; a = a.parentElement) {
|
|
2890
|
+
const s = getComputedStyle(a);
|
|
2891
|
+
const clipsX = /hidden|clip/.test(s.overflowX), clipsY = /hidden|clip/.test(s.overflowY);
|
|
2892
|
+
if (!clipsX && !clipsY) continue;
|
|
2893
|
+
const ar = a.getBoundingClientRect();
|
|
2894
|
+
const outside = clipsX && (cx < ar.left || cx > ar.right) || clipsY && (cy < ar.top || cy > ar.bottom);
|
|
2895
|
+
const tiny = clipsY && ar.height <= 2 || clipsX && ar.width <= 2;
|
|
2896
|
+
if (outside || tiny) {
|
|
2897
|
+
clipped = true;
|
|
2898
|
+
break;
|
|
2899
|
+
}
|
|
2900
|
+
}
|
|
2901
|
+
if (inViewport) {
|
|
2902
|
+
const hit = document.elementFromPoint(cx, cy);
|
|
2903
|
+
if (!(hit && (hit === el || el.contains(hit)))) reason = clipped ? "clipped" : "covered";
|
|
2904
|
+
} else if (clipped) {
|
|
2905
|
+
reason = "clipped";
|
|
2906
|
+
}
|
|
2907
|
+
}
|
|
2908
|
+
return { found: true, reason, attrs, tag };
|
|
2909
|
+
}, selector);
|
|
2910
|
+
if (!r.found) return { found: false, hiddenReason: null, suspiciousName: false };
|
|
2911
|
+
return {
|
|
2912
|
+
found: true,
|
|
2913
|
+
hiddenReason: r.reason ?? null,
|
|
2914
|
+
suspiciousName: looksLikeBaitField(r.attrs),
|
|
2915
|
+
tag: r.tag,
|
|
2916
|
+
name: r.attrs.name ?? void 0
|
|
2917
|
+
};
|
|
2918
|
+
}
|
|
2919
|
+
var HONEYPOT_REASONS, HONEYPOT_NAME_RE;
|
|
2920
|
+
var init_reachability = __esm({
|
|
2921
|
+
"src/lib/reachability.ts"() {
|
|
2922
|
+
"use strict";
|
|
2923
|
+
HONEYPOT_REASONS = /* @__PURE__ */ new Set(["zero-size", "hidden", "transparent", "offscreen", "clipped"]);
|
|
2924
|
+
HONEYPOT_NAME_RE = /^(website|url|homepage|fax|honeypot|honey|hp|hp_[\w-]*|bot|bot_[\w-]*|trap|_gotcha|leave_?blank|do_?not_?fill)$/i;
|
|
2609
2925
|
}
|
|
2610
2926
|
});
|
|
2611
2927
|
|
|
2612
2928
|
// src/lib/actions/handlers/navigation.ts
|
|
2613
|
-
async function navigate(page, step, ctx) {
|
|
2614
|
-
|
|
2615
|
-
|
|
2616
|
-
|
|
2617
|
-
|
|
2929
|
+
async function navigate(page, step, ctx, monitor) {
|
|
2930
|
+
const waitUntil = step.waitUntil || "domcontentloaded";
|
|
2931
|
+
const urlBefore = page.url();
|
|
2932
|
+
const onDialog = (d) => {
|
|
2933
|
+
(d.type() === "beforeunload" ? d.accept() : d.dismiss()).catch(() => void 0);
|
|
2934
|
+
};
|
|
2935
|
+
page.on("dialog", onDialog);
|
|
2936
|
+
try {
|
|
2937
|
+
const nav = page.goto(step.url, { waitUntil, timeout: TIMEOUTS.NAVIGATION });
|
|
2938
|
+
nav.catch(() => void 0);
|
|
2939
|
+
let suspectTimer;
|
|
2940
|
+
const outcome = await Promise.race([
|
|
2941
|
+
nav.then(() => "done"),
|
|
2942
|
+
new Promise((r) => {
|
|
2943
|
+
suspectTimer = setTimeout(() => r("slow"), NAV_COMMIT_SUSPECT_MS);
|
|
2944
|
+
})
|
|
2945
|
+
]).finally(() => clearTimeout(suspectTimer));
|
|
2946
|
+
if (outcome === "slow") {
|
|
2947
|
+
if (page.url() !== urlBefore) {
|
|
2948
|
+
await nav;
|
|
2949
|
+
} else {
|
|
2950
|
+
log9.warn(`navigate: no commit after ${NAV_COMMIT_SUSPECT_MS}ms, falling back to location.assign`);
|
|
2951
|
+
monitor?.reportActivity();
|
|
2952
|
+
await withBudget(page.evaluate((u) => {
|
|
2953
|
+
location.assign(u);
|
|
2954
|
+
}, step.url), 3e3, "location.assign").catch(() => void 0);
|
|
2955
|
+
try {
|
|
2956
|
+
await page.waitForURL((u) => u.toString() !== urlBefore, { timeout: 15e3, waitUntil });
|
|
2957
|
+
} catch {
|
|
2958
|
+
throw new Error(
|
|
2959
|
+
`navigate: the page never left ${urlBefore} for ${step.url} (tried a normal navigation, then location.assign). The site is blocking the route change. Use a click on the site's own link to get there.`
|
|
2960
|
+
);
|
|
2961
|
+
}
|
|
2962
|
+
}
|
|
2963
|
+
}
|
|
2964
|
+
} finally {
|
|
2965
|
+
page.off("dialog", onDialog);
|
|
2966
|
+
}
|
|
2618
2967
|
const stealthScript = contextStealthScripts.get(page.context()) ?? STEALTH_SCRIPT;
|
|
2619
2968
|
try {
|
|
2620
2969
|
await page.evaluate(stealthScript);
|
|
@@ -2630,14 +2979,37 @@ async function navigate(page, step, ctx) {
|
|
|
2630
2979
|
}
|
|
2631
2980
|
}
|
|
2632
2981
|
async function click(page, step, ctx) {
|
|
2633
|
-
await page.click(resolveSelector(step.selector, ctx));
|
|
2982
|
+
await page.click(resolveSelector(step.selector, ctx), { timeout: TIMEOUTS.CLICK });
|
|
2983
|
+
}
|
|
2984
|
+
async function isCombobox(page, selector) {
|
|
2985
|
+
return withBudget(
|
|
2986
|
+
page.evaluate((sel) => {
|
|
2987
|
+
const el = document.querySelector(sel);
|
|
2988
|
+
if (!el) return false;
|
|
2989
|
+
const role = el.getAttribute("role");
|
|
2990
|
+
return role === "combobox" || el.hasAttribute("aria-autocomplete") || el.getAttribute("aria-haspopup") === "listbox" || el.hasAttribute("list") || !!el.closest('[role="combobox"]');
|
|
2991
|
+
}, selector),
|
|
2992
|
+
3e3
|
|
2993
|
+
).catch(() => false);
|
|
2634
2994
|
}
|
|
2635
2995
|
async function fill(page, step, ctx) {
|
|
2636
2996
|
const selector = resolveSelector(step.selector, ctx);
|
|
2637
2997
|
const value = step.value;
|
|
2998
|
+
if (!step.force) {
|
|
2999
|
+
let probe = null;
|
|
3000
|
+
try {
|
|
3001
|
+
probe = await probeField(page, selector);
|
|
3002
|
+
} catch {
|
|
3003
|
+
probe = null;
|
|
3004
|
+
}
|
|
3005
|
+
if (probe?.found && isHoneypotReason(probe.hiddenReason)) {
|
|
3006
|
+
throw new Error(honeypotRefusalMessage(step.selector, probe.hiddenReason, probe.name));
|
|
3007
|
+
}
|
|
3008
|
+
}
|
|
3009
|
+
const combobox = await isCombobox(page, selector);
|
|
2638
3010
|
await page.fill(selector, value);
|
|
2639
3011
|
const stuck = await page.evaluate(
|
|
2640
|
-
({ sel, val }) => {
|
|
3012
|
+
({ sel, val, keepFocus }) => {
|
|
2641
3013
|
const el = document.querySelector(sel);
|
|
2642
3014
|
if (!el) return false;
|
|
2643
3015
|
const proto = el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
|
|
@@ -2653,13 +3025,15 @@ async function fill(page, step, ctx) {
|
|
|
2653
3025
|
}
|
|
2654
3026
|
el.dispatchEvent(new Event("input", { bubbles: true }));
|
|
2655
3027
|
el.dispatchEvent(new Event("change", { bubbles: true }));
|
|
2656
|
-
|
|
2657
|
-
|
|
2658
|
-
|
|
3028
|
+
if (!keepFocus) {
|
|
3029
|
+
try {
|
|
3030
|
+
el.blur();
|
|
3031
|
+
} catch {
|
|
3032
|
+
}
|
|
2659
3033
|
}
|
|
2660
3034
|
return el.value === val;
|
|
2661
3035
|
},
|
|
2662
|
-
{ sel: selector, val: value }
|
|
3036
|
+
{ sel: selector, val: value, keepFocus: combobox }
|
|
2663
3037
|
);
|
|
2664
3038
|
if (!stuck) {
|
|
2665
3039
|
const loc = page.locator(selector);
|
|
@@ -2667,10 +3041,12 @@ async function fill(page, step, ctx) {
|
|
|
2667
3041
|
await loc.click();
|
|
2668
3042
|
await loc.fill("");
|
|
2669
3043
|
await loc.pressSequentially(value, { delay: 15 });
|
|
2670
|
-
|
|
2671
|
-
|
|
2672
|
-
|
|
2673
|
-
|
|
3044
|
+
if (!combobox) {
|
|
3045
|
+
await page.evaluate((sel) => {
|
|
3046
|
+
const el = document.querySelector(sel);
|
|
3047
|
+
el?.blur?.();
|
|
3048
|
+
}, selector);
|
|
3049
|
+
}
|
|
2674
3050
|
} catch {
|
|
2675
3051
|
}
|
|
2676
3052
|
}
|
|
@@ -2694,7 +3070,7 @@ async function rightClick(page, step, ctx) {
|
|
|
2694
3070
|
}
|
|
2695
3071
|
}
|
|
2696
3072
|
async function humanTypeStep(page, step, ctx) {
|
|
2697
|
-
await humanType(page, resolveSelector(step.selector, ctx), step.value, { skipClick: step.skipClick, speed: step.speed });
|
|
3073
|
+
await humanType(page, resolveSelector(step.selector, ctx), step.value, { skipClick: step.skipClick, speed: step.speed, clear: step.clear });
|
|
2698
3074
|
}
|
|
2699
3075
|
async function selectOption(page, step, ctx) {
|
|
2700
3076
|
const selector = resolveSelector(step.selector, ctx);
|
|
@@ -2737,7 +3113,29 @@ async function scroll(page, step, ctx) {
|
|
|
2737
3113
|
{ dy: step.deltaY ?? 0, sel: selector }
|
|
2738
3114
|
);
|
|
2739
3115
|
}
|
|
2740
|
-
async function
|
|
3116
|
+
async function selectAllInFocused(page) {
|
|
3117
|
+
await page.evaluate(() => {
|
|
3118
|
+
const el = document.activeElement;
|
|
3119
|
+
if (el && (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement)) {
|
|
3120
|
+
el.select();
|
|
3121
|
+
} else if (el && el.isContentEditable) {
|
|
3122
|
+
const sel = window.getSelection();
|
|
3123
|
+
sel?.selectAllChildren(el);
|
|
3124
|
+
} else {
|
|
3125
|
+
window.getSelection()?.selectAllChildren(document.body);
|
|
3126
|
+
}
|
|
3127
|
+
});
|
|
3128
|
+
}
|
|
3129
|
+
async function keyboard(page, step, ctx) {
|
|
3130
|
+
if ((step.meta || step.ctrl) && step.key.toLowerCase() === "a" && !step.shift && !step.alt) {
|
|
3131
|
+
await selectAllInFocused(page);
|
|
3132
|
+
return;
|
|
3133
|
+
}
|
|
3134
|
+
if ((step.meta || step.ctrl) && ctx.driver === "extension") {
|
|
3135
|
+
throw new Error(
|
|
3136
|
+
`keyboard: ${step.meta ? "Cmd" : "Ctrl"}+${step.key} can't be sent in extension mode (the relay drops Cmd/Ctrl, so it would arrive as a plain "${step.key}"). Nothing was pressed. Instead: select-all \u2192 keyboard {key:"a", meta:true} (handled in-page); replace a field's text \u2192 fill, or human-type with clear:true; paste \u2192 the paste step.`
|
|
3137
|
+
);
|
|
3138
|
+
}
|
|
2741
3139
|
const mods = [
|
|
2742
3140
|
step.meta ? "Meta" : null,
|
|
2743
3141
|
step.ctrl ? "Control" : null,
|
|
@@ -2808,7 +3206,7 @@ async function typeCode(page, step, ctx) {
|
|
|
2808
3206
|
}
|
|
2809
3207
|
return { typed: code.length };
|
|
2810
3208
|
}
|
|
2811
|
-
var log9;
|
|
3209
|
+
var log9, NAV_COMMIT_SUSPECT_MS;
|
|
2812
3210
|
var init_navigation = __esm({
|
|
2813
3211
|
"src/lib/actions/handlers/navigation.ts"() {
|
|
2814
3212
|
"use strict";
|
|
@@ -2818,8 +3216,11 @@ var init_navigation = __esm({
|
|
|
2818
3216
|
init_persistence();
|
|
2819
3217
|
init_logger();
|
|
2820
3218
|
init_constants();
|
|
3219
|
+
init_budget();
|
|
2821
3220
|
init_resolve_selector();
|
|
3221
|
+
init_reachability();
|
|
2822
3222
|
log9 = createLogger("actions");
|
|
3223
|
+
NAV_COMMIT_SUSPECT_MS = 12e3;
|
|
2823
3224
|
}
|
|
2824
3225
|
});
|
|
2825
3226
|
|
|
@@ -2941,6 +3342,56 @@ async function takeSnapshot(page, ctx, options) {
|
|
|
2941
3342
|
if (rect.bottom < 0 || rect.top > window.innerHeight + 200) return false;
|
|
2942
3343
|
return true;
|
|
2943
3344
|
}
|
|
3345
|
+
function isFillable(el) {
|
|
3346
|
+
if (el.tagName === "TEXTAREA" || el.tagName === "SELECT") return true;
|
|
3347
|
+
if (el.hasAttribute("contenteditable") && el.getAttribute("contenteditable") !== "false") return true;
|
|
3348
|
+
if (el.tagName !== "INPUT") return false;
|
|
3349
|
+
const t = (el.type || "text").toLowerCase();
|
|
3350
|
+
return !["button", "submit", "reset", "image", "checkbox", "radio", "file", "hidden", "range", "color"].includes(t);
|
|
3351
|
+
}
|
|
3352
|
+
function unreachableReason(el) {
|
|
3353
|
+
const rect = el.getBoundingClientRect();
|
|
3354
|
+
const style = getComputedStyle(el);
|
|
3355
|
+
let opacity = 1;
|
|
3356
|
+
for (let n = el; n && n !== document.documentElement; n = n.parentElement) {
|
|
3357
|
+
const o = parseFloat(n === el ? style.opacity : getComputedStyle(n).opacity);
|
|
3358
|
+
const animating = o < 1 && typeof n.getAnimations === "function" && n.getAnimations().length > 0;
|
|
3359
|
+
if (!isNaN(o) && !animating) opacity *= o;
|
|
3360
|
+
}
|
|
3361
|
+
if (opacity < 0.05) return "transparent";
|
|
3362
|
+
const docW = Math.max(document.documentElement.scrollWidth, window.innerWidth);
|
|
3363
|
+
const docH = Math.max(document.documentElement.scrollHeight, window.innerHeight);
|
|
3364
|
+
const left = rect.left + window.scrollX, right = rect.right + window.scrollX;
|
|
3365
|
+
const top = rect.top + window.scrollY, bottom = rect.bottom + window.scrollY;
|
|
3366
|
+
if (right <= 0 || left >= docW || bottom <= 0 || top >= docH) return "offscreen";
|
|
3367
|
+
const cx = rect.left + rect.width / 2, cy = rect.top + rect.height / 2;
|
|
3368
|
+
let clipped = false;
|
|
3369
|
+
for (let a = el.parentElement; a && a !== document.body; a = a.parentElement) {
|
|
3370
|
+
const s = getComputedStyle(a);
|
|
3371
|
+
const clipsX = /hidden|clip/.test(s.overflowX), clipsY = /hidden|clip/.test(s.overflowY);
|
|
3372
|
+
if (!clipsX && !clipsY) continue;
|
|
3373
|
+
const ar = a.getBoundingClientRect();
|
|
3374
|
+
const outside = clipsX && (cx < ar.left || cx > ar.right) || clipsY && (cy < ar.top || cy > ar.bottom);
|
|
3375
|
+
const tiny = clipsY && ar.height <= 2 || clipsX && ar.width <= 2;
|
|
3376
|
+
if (tiny) return "clipped";
|
|
3377
|
+
if (outside) clipped = true;
|
|
3378
|
+
}
|
|
3379
|
+
const inViewport = cx >= 0 && cy >= 0 && cx < window.innerWidth && cy < window.innerHeight;
|
|
3380
|
+
if (inViewport) {
|
|
3381
|
+
const hit = document.elementFromPoint(cx, cy);
|
|
3382
|
+
if (hit && (hit === el || el.contains(hit))) return null;
|
|
3383
|
+
return clipped ? "clipped" : null;
|
|
3384
|
+
}
|
|
3385
|
+
return null;
|
|
3386
|
+
}
|
|
3387
|
+
const baitName = /^(website|url|homepage|fax|honeypot|honey|hp|hp_[\w-]*|bot|bot_[\w-]*|trap|_gotcha|leave_?blank|do_?not_?fill)$/i;
|
|
3388
|
+
function looksLikeBait(el) {
|
|
3389
|
+
const name = el.getAttribute("name") || "", id = el.id || "";
|
|
3390
|
+
if (baitName.test(name) || baitName.test(id)) return true;
|
|
3391
|
+
const type = (el.getAttribute("type") || "text").toLowerCase();
|
|
3392
|
+
const textual = /^(text|email|url|tel|search|number)$/.test(type);
|
|
3393
|
+
return textual && el.getAttribute("tabindex") === "-1";
|
|
3394
|
+
}
|
|
2944
3395
|
function buildSelector(el) {
|
|
2945
3396
|
const path14 = [];
|
|
2946
3397
|
let current = el;
|
|
@@ -2983,6 +3434,11 @@ async function takeSnapshot(page, ctx, options) {
|
|
|
2983
3434
|
if (results.length >= maxElements2) break;
|
|
2984
3435
|
if (interactiveOnly2 && !isInteractive(el)) continue;
|
|
2985
3436
|
if (!isVisible(el)) continue;
|
|
3437
|
+
let suspect;
|
|
3438
|
+
if (isFillable(el)) {
|
|
3439
|
+
if (unreachableReason(el)) continue;
|
|
3440
|
+
if (looksLikeBait(el)) suspect = "honeypot?";
|
|
3441
|
+
}
|
|
2986
3442
|
const tag = el.tagName.toLowerCase();
|
|
2987
3443
|
const role = el.getAttribute("role") || "";
|
|
2988
3444
|
const type = el.type || "";
|
|
@@ -2995,7 +3451,8 @@ async function takeSnapshot(page, ctx, options) {
|
|
|
2995
3451
|
checked: el.checked || false,
|
|
2996
3452
|
disabled: el.disabled || false,
|
|
2997
3453
|
selector: buildSelector(el),
|
|
2998
|
-
isVisible: true
|
|
3454
|
+
isVisible: true,
|
|
3455
|
+
suspect
|
|
2999
3456
|
});
|
|
3000
3457
|
}
|
|
3001
3458
|
return results;
|
|
@@ -3016,6 +3473,7 @@ async function takeSnapshot(page, ctx, options) {
|
|
|
3016
3473
|
const state = [];
|
|
3017
3474
|
if (el.disabled) state.push("disabled");
|
|
3018
3475
|
if (el.checked) state.push("checked");
|
|
3476
|
+
if (el.suspect) state.push(el.suspect);
|
|
3019
3477
|
let description = "";
|
|
3020
3478
|
if (el.placeholder && el.name !== el.placeholder) description = `placeholder="${el.placeholder}"`;
|
|
3021
3479
|
if (el.type && !["text", "submit", "button", ""].includes(el.type)) {
|
|
@@ -4533,12 +4991,15 @@ var init_registry2 = __esm({
|
|
|
4533
4991
|
});
|
|
4534
4992
|
|
|
4535
4993
|
// src/lib/stale-monitor.ts
|
|
4536
|
-
var DEFAULT_STALE_TIMEOUT_MS, StaleStateMonitor, StaleStateError;
|
|
4994
|
+
var DEFAULT_STALE_TIMEOUT_MS, PROBE_BUDGET_MS, UNRESPONSIVE_GRACE_MS, StaleStateMonitor, StaleStateError;
|
|
4537
4995
|
var init_stale_monitor = __esm({
|
|
4538
4996
|
"src/lib/stale-monitor.ts"() {
|
|
4539
4997
|
"use strict";
|
|
4540
4998
|
init_constants();
|
|
4999
|
+
init_budget();
|
|
4541
5000
|
DEFAULT_STALE_TIMEOUT_MS = 2e4;
|
|
5001
|
+
PROBE_BUDGET_MS = 3e3;
|
|
5002
|
+
UNRESPONSIVE_GRACE_MS = 8e3;
|
|
4542
5003
|
StaleStateMonitor = class _StaleStateMonitor {
|
|
4543
5004
|
page;
|
|
4544
5005
|
timeoutMs;
|
|
@@ -4550,21 +5011,24 @@ var init_stale_monitor = __esm({
|
|
|
4550
5011
|
}
|
|
4551
5012
|
async snapshot() {
|
|
4552
5013
|
try {
|
|
4553
|
-
const
|
|
4554
|
-
|
|
5014
|
+
const url = this.page.url();
|
|
5015
|
+
let unresponsive = false;
|
|
5016
|
+
const state = await withBudget(
|
|
4555
5017
|
this.page.evaluate(() => ({
|
|
4556
5018
|
documentReadyState: document.readyState,
|
|
4557
5019
|
bodyTextLength: document.body?.innerText?.length || 0,
|
|
4558
5020
|
elementCount: document.querySelectorAll("*").length
|
|
4559
|
-
}))
|
|
4560
|
-
|
|
4561
|
-
|
|
4562
|
-
|
|
4563
|
-
|
|
4564
|
-
|
|
5021
|
+
})),
|
|
5022
|
+
PROBE_BUDGET_MS,
|
|
5023
|
+
"state probe"
|
|
5024
|
+
).catch((e) => {
|
|
5025
|
+
unresponsive = e instanceof BudgetExceededError;
|
|
5026
|
+
return { documentReadyState: "unknown", bodyTextLength: 0, elementCount: 0 };
|
|
5027
|
+
});
|
|
4565
5028
|
return {
|
|
4566
5029
|
url,
|
|
4567
5030
|
...state,
|
|
5031
|
+
unresponsive,
|
|
4568
5032
|
timestamp: Date.now()
|
|
4569
5033
|
};
|
|
4570
5034
|
} catch {
|
|
@@ -4608,6 +5072,8 @@ var init_stale_monitor = __esm({
|
|
|
4608
5072
|
return new Promise((resolve, reject) => {
|
|
4609
5073
|
let resolved = false;
|
|
4610
5074
|
let checkInterval = null;
|
|
5075
|
+
let probing = false;
|
|
5076
|
+
let unresponsiveSince = null;
|
|
4611
5077
|
const cleanup = () => {
|
|
4612
5078
|
resolved = true;
|
|
4613
5079
|
if (checkInterval) clearInterval(checkInterval);
|
|
@@ -4615,11 +5081,25 @@ var init_stale_monitor = __esm({
|
|
|
4615
5081
|
this.timer = null;
|
|
4616
5082
|
};
|
|
4617
5083
|
checkInterval = setInterval(async () => {
|
|
4618
|
-
if (resolved) return;
|
|
5084
|
+
if (resolved || probing) return;
|
|
4619
5085
|
const elapsed = Date.now() - this.lastActivity;
|
|
4620
5086
|
if (elapsed < this.timeoutMs) return;
|
|
5087
|
+
probing = true;
|
|
4621
5088
|
try {
|
|
4622
5089
|
const currentSnapshot = await this.snapshot();
|
|
5090
|
+
if (resolved) return;
|
|
5091
|
+
if (currentSnapshot.unresponsive) {
|
|
5092
|
+
unresponsiveSince ??= Date.now();
|
|
5093
|
+
if (Date.now() - unresponsiveSince < UNRESPONSIVE_GRACE_MS) return;
|
|
5094
|
+
cleanup();
|
|
5095
|
+
reject(new StaleStateError(
|
|
5096
|
+
`The tab stopped responding: its JavaScript did not answer for ${Math.round((Date.now() - unresponsiveSince) / 1e3)}s (a blocking dialog, a frozen re-render, or a navigation that never finished)`,
|
|
5097
|
+
this.timeoutMs,
|
|
5098
|
+
true
|
|
5099
|
+
));
|
|
5100
|
+
return;
|
|
5101
|
+
}
|
|
5102
|
+
unresponsiveSince = null;
|
|
4623
5103
|
if (_StaleStateMonitor.hasChanged(beforeSnapshot, currentSnapshot)) {
|
|
4624
5104
|
this.lastActivity = Date.now();
|
|
4625
5105
|
return;
|
|
@@ -4627,6 +5107,8 @@ var init_stale_monitor = __esm({
|
|
|
4627
5107
|
} catch {
|
|
4628
5108
|
this.lastActivity = Date.now();
|
|
4629
5109
|
return;
|
|
5110
|
+
} finally {
|
|
5111
|
+
probing = false;
|
|
4630
5112
|
}
|
|
4631
5113
|
cleanup();
|
|
4632
5114
|
reject(new StaleStateError(
|
|
@@ -4650,10 +5132,13 @@ var init_stale_monitor = __esm({
|
|
|
4650
5132
|
};
|
|
4651
5133
|
StaleStateError = class extends Error {
|
|
4652
5134
|
timeoutMs;
|
|
4653
|
-
|
|
5135
|
+
/** True when the page stopped answering at all (vs. answering but not changing). */
|
|
5136
|
+
unresponsive;
|
|
5137
|
+
constructor(message, timeoutMs, unresponsive = false) {
|
|
4654
5138
|
super(message);
|
|
4655
5139
|
this.name = "StaleStateError";
|
|
4656
5140
|
this.timeoutMs = timeoutMs;
|
|
5141
|
+
this.unresponsive = unresponsive;
|
|
4657
5142
|
}
|
|
4658
5143
|
};
|
|
4659
5144
|
}
|
|
@@ -4882,10 +5367,14 @@ async function capturePageState(page, ctx, opts) {
|
|
|
4882
5367
|
url = page.url();
|
|
4883
5368
|
} catch {
|
|
4884
5369
|
}
|
|
4885
|
-
const title = await page.title().catch(() => "");
|
|
5370
|
+
const title = await withBudget(page.title(), 2e3, "title").catch(() => "");
|
|
4886
5371
|
if (!screenshot2) return { url, title };
|
|
4887
5372
|
try {
|
|
4888
|
-
const buf = await
|
|
5373
|
+
const buf = await withBudget(
|
|
5374
|
+
page.screenshot({ type: "jpeg", quality: 50, fullPage: false, timeout: 12e3 }),
|
|
5375
|
+
13e3,
|
|
5376
|
+
"screenshot"
|
|
5377
|
+
);
|
|
4889
5378
|
const screenshotUrl = saveScreenshot(buf, `${namePrefix}-${Date.now()}.jpg`, ctx.screenshotDir, ctx.publicUrl);
|
|
4890
5379
|
return { url, title, screenshotUrl };
|
|
4891
5380
|
} catch {
|
|
@@ -4896,6 +5385,7 @@ var init_page_state = __esm({
|
|
|
4896
5385
|
"src/lib/page-state.ts"() {
|
|
4897
5386
|
"use strict";
|
|
4898
5387
|
init_screenshot();
|
|
5388
|
+
init_budget();
|
|
4899
5389
|
}
|
|
4900
5390
|
});
|
|
4901
5391
|
|
|
@@ -5097,6 +5587,24 @@ function buildCurl(method, url, headers, auth, body) {
|
|
|
5097
5587
|
parts.push(` '${url}'`);
|
|
5098
5588
|
return parts.join(" \\\n");
|
|
5099
5589
|
}
|
|
5590
|
+
function serverErrorsFrom(requests) {
|
|
5591
|
+
const out = [];
|
|
5592
|
+
for (const r of requests) {
|
|
5593
|
+
if (r.responseStatus < 400) continue;
|
|
5594
|
+
let body;
|
|
5595
|
+
if (r.responseBody !== void 0 && r.responseBody !== null) {
|
|
5596
|
+
const raw = typeof r.responseBody === "string" ? r.responseBody : JSON.stringify(r.responseBody);
|
|
5597
|
+
const flat = raw.replace(/\s+/g, " ").trim();
|
|
5598
|
+
body = flat.length > SERVER_ERROR_BODY_MAX ? flat.slice(0, SERVER_ERROR_BODY_MAX) + "\u2026" : flat;
|
|
5599
|
+
if (/^\s*<(!doctype|html)/i.test(raw)) {
|
|
5600
|
+
const m = raw.match(/<title[^>]*>([^<]*)<\/title>|<h1[^>]*>([^<]*)<\/h1>/i);
|
|
5601
|
+
body = m ? `[html] ${(m[1] || m[2] || "").trim()}` : "[html page]";
|
|
5602
|
+
}
|
|
5603
|
+
}
|
|
5604
|
+
out.push({ stepIndex: r.triggeredAtStep, method: r.method, url: r.url, status: r.responseStatus, body });
|
|
5605
|
+
}
|
|
5606
|
+
return out;
|
|
5607
|
+
}
|
|
5100
5608
|
function extractAuth(requests) {
|
|
5101
5609
|
const auth = { cookies: {}, tokens: {} };
|
|
5102
5610
|
for (const req of requests) {
|
|
@@ -5182,7 +5690,7 @@ function buildCapturedApi(requests) {
|
|
|
5182
5690
|
}
|
|
5183
5691
|
return apis.sort((a, b) => b.endpoints.length - a.endpoints.length);
|
|
5184
5692
|
}
|
|
5185
|
-
var SKIP_RESOURCE_TYPES, SKIP_EXTENSIONS, BROWSER_NOISE_HEADERS, AUTH_HEADER_PATTERNS, ID_PATTERNS, ApiCapture;
|
|
5693
|
+
var SKIP_RESOURCE_TYPES, SKIP_EXTENSIONS, BROWSER_NOISE_HEADERS, AUTH_HEADER_PATTERNS, ID_PATTERNS, ApiCapture, SERVER_ERROR_BODY_MAX;
|
|
5186
5694
|
var init_api_capture = __esm({
|
|
5187
5695
|
"src/lib/api-capture.ts"() {
|
|
5188
5696
|
"use strict";
|
|
@@ -5352,10 +5860,19 @@ var init_api_capture = __esm({
|
|
|
5352
5860
|
await new Promise((r) => setTimeout(r, 100));
|
|
5353
5861
|
}
|
|
5354
5862
|
}
|
|
5863
|
+
/** Requests fired but not yet answered. */
|
|
5864
|
+
hasPending() {
|
|
5865
|
+
return this.pendingRequests.size > 0;
|
|
5866
|
+
}
|
|
5355
5867
|
getResults() {
|
|
5356
5868
|
return buildCapturedApi(this.requests);
|
|
5357
5869
|
}
|
|
5870
|
+
/** Every HTTP >= 400 response, compact, in capture order. */
|
|
5871
|
+
getServerErrors() {
|
|
5872
|
+
return serverErrorsFrom(this.requests);
|
|
5873
|
+
}
|
|
5358
5874
|
};
|
|
5875
|
+
SERVER_ERROR_BODY_MAX = 600;
|
|
5359
5876
|
}
|
|
5360
5877
|
});
|
|
5361
5878
|
|
|
@@ -5534,10 +6051,35 @@ function classifyError(err, step) {
|
|
|
5534
6051
|
if (step.type === "solve-captcha") return "captcha-unsolvable";
|
|
5535
6052
|
return "action-failed";
|
|
5536
6053
|
}
|
|
6054
|
+
async function refineError(page, step, ctx, errorType, message) {
|
|
6055
|
+
if (errorType !== "stale-state" && errorType !== "timeout") return { errorType, message };
|
|
6056
|
+
const raw = step.selector;
|
|
6057
|
+
if (typeof raw !== "string") return { errorType, message };
|
|
6058
|
+
let resolved;
|
|
6059
|
+
try {
|
|
6060
|
+
resolved = resolveSelector(raw, ctx);
|
|
6061
|
+
} catch {
|
|
6062
|
+
return { errorType, message };
|
|
6063
|
+
}
|
|
6064
|
+
try {
|
|
6065
|
+
const probe = page.evaluate((sel) => !!document.querySelector(sel), resolved);
|
|
6066
|
+
const budget = new Promise((r) => setTimeout(() => r(null), TIMING.FAILURE_PROBE));
|
|
6067
|
+
const found = await Promise.race([probe, budget]);
|
|
6068
|
+
if (found !== false) return { errorType, message };
|
|
6069
|
+
} catch {
|
|
6070
|
+
return { errorType, message };
|
|
6071
|
+
}
|
|
6072
|
+
const shown = resolved === raw ? `"${raw}"` : `"${raw}" (\u2192 ${resolved})`;
|
|
6073
|
+
return {
|
|
6074
|
+
errorType: "element-not-found",
|
|
6075
|
+
message: `No element matches ${shown} on the current page \u2014 the step waited for it to appear and it never did. It most likely existed when you looked and was re-rendered away before this step ran. (${message})`
|
|
6076
|
+
};
|
|
6077
|
+
}
|
|
5537
6078
|
function isRetryable(errorType) {
|
|
5538
6079
|
return errorType === "stale-state" || errorType === "timeout" || errorType === "element-not-found";
|
|
5539
6080
|
}
|
|
5540
|
-
function getSuggestion(errorType, step) {
|
|
6081
|
+
function getSuggestion(errorType, step, message = "") {
|
|
6082
|
+
if (message.includes("tab stopped responding")) return UNRESPONSIVE_SUGGESTION;
|
|
5541
6083
|
const usedEphemeralRef = typeof step.selector === "string" && step.selector.startsWith("@e");
|
|
5542
6084
|
if (usedEphemeralRef && (errorType === "element-not-found" || errorType === "timeout" || errorType === "stale-state")) {
|
|
5543
6085
|
return "The @e ref went stale \u2014 the page (likely a React/SPA) re-rendered and dropped it since the snapshot. Take a fresh `snapshot`/`find` right before acting, or save the element as a durable `@a:` anchor with the `remember` tool.";
|
|
@@ -5546,7 +6088,7 @@ function getSuggestion(errorType, step) {
|
|
|
5546
6088
|
case "stale-state":
|
|
5547
6089
|
return "The page stopped responding. The step may have triggered a very slow load or the server may be unreachable.";
|
|
5548
6090
|
case "element-not-found":
|
|
5549
|
-
return
|
|
6091
|
+
return "Selector not found. Either it is wrong, or the page re-rendered the element away (SPAs swap DOM nodes on every state change, so a selector that worked a second ago can return nothing now). Take a fresh `snapshot`/`find` immediately before acting and retry; if it keeps vanishing, `wait-for` it first.";
|
|
5550
6092
|
case "navigation-failed":
|
|
5551
6093
|
return "Navigation failed. The URL may be unreachable, blocked, or require authentication.";
|
|
5552
6094
|
case "captcha-unsolvable":
|
|
@@ -5557,10 +6099,11 @@ function getSuggestion(errorType, step) {
|
|
|
5557
6099
|
return void 0;
|
|
5558
6100
|
}
|
|
5559
6101
|
}
|
|
5560
|
-
var DEFAULT_STALE_TIMEOUT_MS2, PipelineRunner;
|
|
6102
|
+
var DEFAULT_STALE_TIMEOUT_MS2, UNRESPONSIVE_SUGGESTION, PipelineRunner;
|
|
5561
6103
|
var init_pipeline = __esm({
|
|
5562
6104
|
"src/lib/pipeline.ts"() {
|
|
5563
6105
|
"use strict";
|
|
6106
|
+
init_site_protection();
|
|
5564
6107
|
init_registry2();
|
|
5565
6108
|
init_types();
|
|
5566
6109
|
init_stale_monitor();
|
|
@@ -5572,12 +6115,20 @@ var init_pipeline = __esm({
|
|
|
5572
6115
|
init_constants();
|
|
5573
6116
|
init_component_map();
|
|
5574
6117
|
init_resolve_selector();
|
|
6118
|
+
init_constants();
|
|
6119
|
+
init_budget();
|
|
5575
6120
|
DEFAULT_STALE_TIMEOUT_MS2 = 2e4;
|
|
6121
|
+
UNRESPONSIVE_SUGGESTION = "The tab's JavaScript stopped answering during this step. Run a single `read` or `snapshot` on its own to see if it recovered (it usually does). If this step froze it, don't repeat it as-is: for a route change in a single-page app use `evaluate` with `location.assign(url)`; for a click, use a fresh `@e` ref from `find`; avoid `evaluate` calls that scroll or re-render.";
|
|
5576
6122
|
PipelineRunner = class {
|
|
5577
|
-
constructor(ctx) {
|
|
6123
|
+
constructor(ctx, hooks) {
|
|
5578
6124
|
this.ctx = ctx;
|
|
6125
|
+
this.hooks = hooks;
|
|
5579
6126
|
}
|
|
5580
6127
|
ctx;
|
|
6128
|
+
hooks;
|
|
6129
|
+
/** Live progress, readable while run() is still pending — lets an outer
|
|
6130
|
+
* watchdog report WHICH step hung and what already finished. */
|
|
6131
|
+
progress = { stepIndex: -1, results: [] };
|
|
5581
6132
|
async run(initialPage, pipeline) {
|
|
5582
6133
|
const tracker = new TabTracker(initialPage.context(), initialPage);
|
|
5583
6134
|
try {
|
|
@@ -5595,6 +6146,7 @@ var init_pipeline = __esm({
|
|
|
5595
6146
|
const continueOnError = opts.continueOnError ?? false;
|
|
5596
6147
|
const results = [];
|
|
5597
6148
|
const obstacles = [];
|
|
6149
|
+
this.progress.results = results;
|
|
5598
6150
|
const navStep = pipeline.steps.find((s) => s.type === "navigate");
|
|
5599
6151
|
try {
|
|
5600
6152
|
const host = new URL(navStep?.url || safePageUrl(initialPage) || "http://x").hostname;
|
|
@@ -5612,27 +6164,41 @@ var init_pipeline = __esm({
|
|
|
5612
6164
|
if (capture) capture.start();
|
|
5613
6165
|
const finishCapture = async () => {
|
|
5614
6166
|
if (!capture) return void 0;
|
|
6167
|
+
await capture.drain(TIMING.CAPTURE_SETTLE, TIMING.CAPTURE_PENDING_MAX);
|
|
5615
6168
|
capture.stop();
|
|
6169
|
+
for (const se of capture.getServerErrors()) {
|
|
6170
|
+
const r = results.find((x) => x.stepIndex === se.stepIndex);
|
|
6171
|
+
if (r) (r.serverErrors ??= []).push(se);
|
|
6172
|
+
}
|
|
5616
6173
|
return capture.getResults();
|
|
5617
6174
|
};
|
|
5618
6175
|
for (let i = 0; i < pipeline.steps.length; i++) {
|
|
5619
6176
|
if (capture) capture.setStep(i);
|
|
5620
6177
|
const step = pipeline.steps[i];
|
|
6178
|
+
this.progress.stepIndex = i;
|
|
5621
6179
|
const page = tracker.active();
|
|
5622
6180
|
const urlBefore = safePageUrl(page);
|
|
5623
6181
|
const monitor = new StaleStateMonitor(page, staleTimeoutMs);
|
|
5624
6182
|
let stepResult;
|
|
5625
6183
|
try {
|
|
6184
|
+
await this.hooks?.before(page, step);
|
|
5626
6185
|
stepResult = await monitor.withMonitoring(async () => {
|
|
5627
6186
|
const r = await executeAction(page, step, this.ctx, monitor);
|
|
6187
|
+
if (step.type === "navigate") await this.hooks?.after(page, step.url);
|
|
5628
6188
|
r.stepIndex = i;
|
|
5629
6189
|
return r;
|
|
5630
6190
|
});
|
|
5631
6191
|
} catch (err) {
|
|
6192
|
+
if (err instanceof HeadedRequiredError) {
|
|
6193
|
+
Object.assign(err, { stepIndex: i, results, obstacles });
|
|
6194
|
+
await finishCapture();
|
|
6195
|
+
throw err;
|
|
6196
|
+
}
|
|
5632
6197
|
const asError = err instanceof Error ? err : new Error(String(err));
|
|
5633
|
-
const
|
|
6198
|
+
const refined = await refineError(tracker.active(), step, this.ctx, classifyError(asError, step), asError.message);
|
|
6199
|
+
const errorType = refined.errorType;
|
|
5634
6200
|
const pageState = await capturePageState(tracker.active(), this.ctx, { screenshot: true });
|
|
5635
|
-
stepResult = failedStepResult(step,
|
|
6201
|
+
stepResult = failedStepResult(step, refined.message, Date.now() - startTime, i);
|
|
5636
6202
|
results.push(stepResult);
|
|
5637
6203
|
recordAnchor(step, false);
|
|
5638
6204
|
return {
|
|
@@ -5646,9 +6212,9 @@ var init_pipeline = __esm({
|
|
|
5646
6212
|
failedAtStep: i,
|
|
5647
6213
|
failedStep: step,
|
|
5648
6214
|
errorType,
|
|
5649
|
-
message:
|
|
6215
|
+
message: refined.message,
|
|
5650
6216
|
pageState,
|
|
5651
|
-
suggestion: getSuggestion(errorType, step),
|
|
6217
|
+
suggestion: getSuggestion(errorType, step, refined.message),
|
|
5652
6218
|
retryable: isRetryable(errorType)
|
|
5653
6219
|
},
|
|
5654
6220
|
durationMs: Date.now() - startTime,
|
|
@@ -5682,8 +6248,16 @@ var init_pipeline = __esm({
|
|
|
5682
6248
|
}
|
|
5683
6249
|
results.push(stepResult);
|
|
5684
6250
|
if (!stepResult.ok && !continueOnError) {
|
|
6251
|
+
const refined = await refineError(
|
|
6252
|
+
tracker.active(),
|
|
6253
|
+
step,
|
|
6254
|
+
this.ctx,
|
|
6255
|
+
classifyError(new Error(stepResult.error || ""), step),
|
|
6256
|
+
stepResult.error || "Step failed"
|
|
6257
|
+
);
|
|
6258
|
+
stepResult.error = refined.message;
|
|
6259
|
+
const errorType = refined.errorType;
|
|
5685
6260
|
const pageState = await capturePageState(tracker.active(), this.ctx, { screenshot: true });
|
|
5686
|
-
const errorType = classifyError(new Error(stepResult.error || ""), step);
|
|
5687
6261
|
return {
|
|
5688
6262
|
ok: false,
|
|
5689
6263
|
completedSteps: i,
|
|
@@ -5697,7 +6271,7 @@ var init_pipeline = __esm({
|
|
|
5697
6271
|
errorType,
|
|
5698
6272
|
message: stepResult.error || "Step failed",
|
|
5699
6273
|
pageState,
|
|
5700
|
-
suggestion: getSuggestion(errorType, step),
|
|
6274
|
+
suggestion: getSuggestion(errorType, step, refined.message),
|
|
5701
6275
|
retryable: isRetryable(errorType)
|
|
5702
6276
|
},
|
|
5703
6277
|
durationMs: Date.now() - startTime,
|
|
@@ -5707,7 +6281,7 @@ var init_pipeline = __esm({
|
|
|
5707
6281
|
if (step.type === "navigate" && continueOnObstacle) {
|
|
5708
6282
|
const obstacleStart = Date.now();
|
|
5709
6283
|
const obstaclePage = tracker.active();
|
|
5710
|
-
const obstacle = await detectObstacles(obstaclePage);
|
|
6284
|
+
const obstacle = await withBudget(detectObstacles(obstaclePage), 1e4, "obstacle check").catch(() => null);
|
|
5711
6285
|
if (obstacle) {
|
|
5712
6286
|
const resolution = await resolveObstacle(obstaclePage, obstacle, this.ctx, monitor);
|
|
5713
6287
|
obstacles.push({
|
|
@@ -10119,7 +10693,7 @@ var init_cdp_relay = __esm({
|
|
|
10119
10693
|
return {};
|
|
10120
10694
|
case "Browser.close":
|
|
10121
10695
|
return {};
|
|
10122
|
-
//
|
|
10696
|
+
// Disconnect without closing the user's Chrome.
|
|
10123
10697
|
case "Target.setDiscoverTargets":
|
|
10124
10698
|
return {};
|
|
10125
10699
|
case "Target.getTargets":
|
|
@@ -10162,7 +10736,7 @@ var init_cdp_relay = __esm({
|
|
|
10162
10736
|
return await Promise.race([
|
|
10163
10737
|
first,
|
|
10164
10738
|
new Promise((_, reject) => {
|
|
10165
|
-
const t = setTimeout(() => reject(new Error("screenshot timed out (no compositor frame)")),
|
|
10739
|
+
const t = setTimeout(() => reject(new Error("screenshot timed out (no compositor frame)")), 5e3);
|
|
10166
10740
|
t.unref?.();
|
|
10167
10741
|
})
|
|
10168
10742
|
]);
|
|
@@ -10222,10 +10796,29 @@ var init_cdp_relay = __esm({
|
|
|
10222
10796
|
});
|
|
10223
10797
|
|
|
10224
10798
|
// src/lib/execution/pipeline-executor.ts
|
|
10799
|
+
function blankPipelineResult(pipeline, modeUsed, error) {
|
|
10800
|
+
return {
|
|
10801
|
+
ok: false,
|
|
10802
|
+
completedSteps: 0,
|
|
10803
|
+
totalSteps: pipeline.steps.length,
|
|
10804
|
+
results: [],
|
|
10805
|
+
obstacles: [],
|
|
10806
|
+
error: {
|
|
10807
|
+
failedAtStep: 0,
|
|
10808
|
+
failedStep: pipeline.steps[0],
|
|
10809
|
+
pageState: { url: "", title: "" },
|
|
10810
|
+
...error
|
|
10811
|
+
},
|
|
10812
|
+
durationMs: 0,
|
|
10813
|
+
modeUsed,
|
|
10814
|
+
finalState: { url: "", title: "" }
|
|
10815
|
+
};
|
|
10816
|
+
}
|
|
10225
10817
|
var import_playwright_core, log18, PipelineExecutor;
|
|
10226
10818
|
var init_pipeline_executor = __esm({
|
|
10227
10819
|
"src/lib/execution/pipeline-executor.ts"() {
|
|
10228
10820
|
"use strict";
|
|
10821
|
+
init_site_protection();
|
|
10229
10822
|
import_playwright_core = require("playwright-core");
|
|
10230
10823
|
init_daemon();
|
|
10231
10824
|
init_pipeline();
|
|
@@ -10245,8 +10838,8 @@ var init_pipeline_executor = __esm({
|
|
|
10245
10838
|
this.deps = deps;
|
|
10246
10839
|
}
|
|
10247
10840
|
deps;
|
|
10248
|
-
/**
|
|
10249
|
-
*
|
|
10841
|
+
/** Shared runtime hook read by local and extension execution;
|
|
10842
|
+
* execute() clears it when the call finishes. */
|
|
10250
10843
|
pendingElicitOtp;
|
|
10251
10844
|
/** One pipeline per real tab at a time: chrome.debugger and the CDP relay
|
|
10252
10845
|
* can't share a tab, so concurrent executes on the same tab queue up here. */
|
|
@@ -10277,8 +10870,24 @@ var init_pipeline_executor = __esm({
|
|
|
10277
10870
|
const forcedMode = opts.mode;
|
|
10278
10871
|
const autoEscalate = opts.autoEscalate !== false;
|
|
10279
10872
|
const instanceId = opts.instanceId || DEFAULT_INSTANCE;
|
|
10873
|
+
if (forcedMode === "extension") {
|
|
10874
|
+
return blankPipelineResult(pipeline, "extension", {
|
|
10875
|
+
errorType: "action-failed",
|
|
10876
|
+
message: 'mode="extension" requires options.tabId (the real-Chrome tab to drive).',
|
|
10877
|
+
suggestion: "Call the `tabs` tool (or GET /extension/tabs) to list open tabs, then pass that tab id. Nothing was launched.",
|
|
10878
|
+
retryable: false
|
|
10879
|
+
});
|
|
10880
|
+
}
|
|
10280
10881
|
const firstNav = pipeline.steps.find((s) => s.type === "navigate");
|
|
10281
10882
|
const domain = firstNav ? new URL(firstNav.url).hostname : null;
|
|
10883
|
+
if (!firstNav && !this.deps.daemon.findLiveMode(instanceId)) {
|
|
10884
|
+
return blankPipelineResult(pipeline, forcedMode || "headless", {
|
|
10885
|
+
errorType: "action-failed",
|
|
10886
|
+
message: `No navigate step and no live browser for instanceId "${instanceId}" to act on.`,
|
|
10887
|
+
suggestion: "Add a navigate step, or reattach to a live window (check `status` / `iframer instances` for instanceId). To drive a real Chrome tab, pass options.tabId from the `tabs` tool. Nothing was launched.",
|
|
10888
|
+
retryable: false
|
|
10889
|
+
});
|
|
10890
|
+
}
|
|
10282
10891
|
const availableModes = this.deps.availableModes();
|
|
10283
10892
|
const liveMode = forcedMode ? null : this.deps.daemon.findLiveMode(instanceId);
|
|
10284
10893
|
let mode;
|
|
@@ -10292,6 +10901,7 @@ var init_pipeline_executor = __esm({
|
|
|
10292
10901
|
mode = availableModes[0] || "headless";
|
|
10293
10902
|
}
|
|
10294
10903
|
let result = await this.executeWithMode(userId, token, pipeline, mode, instanceId);
|
|
10904
|
+
mode = result.modeUsed || mode;
|
|
10295
10905
|
if (!result.ok && autoEscalate && domain && result.error?.errorType === "bot-blocked") {
|
|
10296
10906
|
const failedMode = mode;
|
|
10297
10907
|
if (domain) this.deps.domainModes.recordFailure(domain, failedMode, result.error?.message || "blocked");
|
|
@@ -10328,11 +10938,11 @@ var init_pipeline_executor = __esm({
|
|
|
10328
10938
|
}
|
|
10329
10939
|
return result;
|
|
10330
10940
|
}
|
|
10331
|
-
async executeWithMode(userId, token, pipeline, mode, instanceId = DEFAULT_INSTANCE) {
|
|
10941
|
+
async executeWithMode(userId, token, pipeline, mode, instanceId = DEFAULT_INSTANCE, respectful = false) {
|
|
10332
10942
|
if (mode === "docker-headful") {
|
|
10333
|
-
return this.executeDocker(userId, token, pipeline);
|
|
10943
|
+
return this.executeDocker(userId, token, pipeline, respectful);
|
|
10334
10944
|
}
|
|
10335
|
-
return this.executeLocal(userId, token, pipeline, mode, instanceId);
|
|
10945
|
+
return this.executeLocal(userId, token, pipeline, mode, instanceId, respectful);
|
|
10336
10946
|
}
|
|
10337
10947
|
/** Execute against the user's real Chrome tab via the extension CDP relay,
|
|
10338
10948
|
* using the SAME PipelineRunner as every other mode. No session inject/extract
|
|
@@ -10341,6 +10951,7 @@ var init_pipeline_executor = __esm({
|
|
|
10341
10951
|
const startTime = Date.now();
|
|
10342
10952
|
const relay = new CdpRelay(tabId, clientId, pipeline.options?.focus);
|
|
10343
10953
|
let browser;
|
|
10954
|
+
let runner;
|
|
10344
10955
|
try {
|
|
10345
10956
|
await relay.start();
|
|
10346
10957
|
browser = await import_playwright_core.chromium.connectOverCDP(relay.httpEndpoint(), { timeout: 3e4 });
|
|
@@ -10353,7 +10964,8 @@ var init_pipeline_executor = __esm({
|
|
|
10353
10964
|
if (!page) throw new Error("no page available for the tab (is it still open?)");
|
|
10354
10965
|
const ctx = this.deps.refStore.makeContext(userId, token);
|
|
10355
10966
|
if (this.pendingElicitOtp) ctx.elicitOtp = this.pendingElicitOtp;
|
|
10356
|
-
|
|
10967
|
+
ctx.driver = "extension";
|
|
10968
|
+
runner = new PipelineRunner(ctx);
|
|
10357
10969
|
const typeChars = pipeline.steps.reduce((n, s) => {
|
|
10358
10970
|
const v = s.value;
|
|
10359
10971
|
return (s.type === "human-type" || s.type === "type-code") && typeof v === "string" ? n + v.length : n;
|
|
@@ -10370,7 +10982,7 @@ var init_pipeline_executor = __esm({
|
|
|
10370
10982
|
watchdog = setTimeout(
|
|
10371
10983
|
() => reject(
|
|
10372
10984
|
new Error(
|
|
10373
|
-
`pipeline exceeded ${Math.round(capMs / 1e3)}s \u2014
|
|
10985
|
+
`pipeline exceeded ${Math.round(capMs / 1e3)}s \u2014 a call into the tab never returned.`
|
|
10374
10986
|
)
|
|
10375
10987
|
),
|
|
10376
10988
|
capMs
|
|
@@ -10394,23 +11006,26 @@ var init_pipeline_executor = __esm({
|
|
|
10394
11006
|
} catch (err) {
|
|
10395
11007
|
const msg = getErrorMessage(err);
|
|
10396
11008
|
const stalled = msg.includes("pipeline exceeded");
|
|
11009
|
+
const done = runner ? runner.progress.results.filter((r) => r.ok) : [];
|
|
11010
|
+
const hungAt = stalled && runner && runner.progress.stepIndex >= 0 ? runner.progress.stepIndex : 0;
|
|
11011
|
+
const hungStep = pipeline.steps[hungAt];
|
|
10397
11012
|
return {
|
|
10398
11013
|
ok: false,
|
|
10399
|
-
completedSteps: 0,
|
|
11014
|
+
completedSteps: stalled ? done.length : 0,
|
|
10400
11015
|
totalSteps: pipeline.steps.length,
|
|
10401
|
-
results: [],
|
|
11016
|
+
results: stalled ? done : [],
|
|
10402
11017
|
finalState: { url: "", title: "" },
|
|
10403
11018
|
obstacles: [],
|
|
10404
11019
|
durationMs: Date.now() - startTime,
|
|
10405
11020
|
modeUsed: "extension",
|
|
10406
11021
|
error: {
|
|
10407
|
-
failedAtStep:
|
|
10408
|
-
failedStep:
|
|
10409
|
-
errorType: "action-failed",
|
|
10410
|
-
message: `Extension mode failed: ${msg}`,
|
|
11022
|
+
failedAtStep: hungAt,
|
|
11023
|
+
failedStep: hungStep,
|
|
11024
|
+
errorType: stalled ? "stale-state" : "action-failed",
|
|
11025
|
+
message: stalled ? `Extension mode: step ${hungAt} (${hungStep?.type}) never returned; ${msg}` : `Extension mode failed: ${msg}`,
|
|
10411
11026
|
pageState: { url: "", title: "" },
|
|
10412
|
-
suggestion: stalled ?
|
|
10413
|
-
retryable:
|
|
11027
|
+
suggestion: stalled ? `Step ${hungAt} (${hungStep?.type}) hung inside the tab. Steps before it completed. Run a single \`read\` or \`snapshot\` on its own to check the tab recovered (it usually does), then continue from that step in its own execute call. Don't repeat the same step unchanged: for a route change use \`evaluate\` with \`location.assign(url)\`, for a click use a fresh \`@e\` ref from \`find\`. If the tab stays unresponsive, tell the user.` : "Ensure the iframer extension is connected (green dot) and the tab is still open. See chrome://extensions.",
|
|
11028
|
+
retryable: stalled
|
|
10414
11029
|
}
|
|
10415
11030
|
};
|
|
10416
11031
|
} finally {
|
|
@@ -10425,7 +11040,7 @@ var init_pipeline_executor = __esm({
|
|
|
10425
11040
|
}
|
|
10426
11041
|
}
|
|
10427
11042
|
/** Execute via Docker session-manager. */
|
|
10428
|
-
async executeDocker(userId, token, pipeline) {
|
|
11043
|
+
async executeDocker(userId, token, pipeline, respectful = false) {
|
|
10429
11044
|
let session = getSession(userId);
|
|
10430
11045
|
if (!session) {
|
|
10431
11046
|
const firstNav = pipeline.steps.find((s) => s.type === "navigate");
|
|
@@ -10434,7 +11049,7 @@ var init_pipeline_executor = __esm({
|
|
|
10434
11049
|
}
|
|
10435
11050
|
resetTimeout(userId);
|
|
10436
11051
|
const ctx = this.deps.refStore.makeContext(userId, token);
|
|
10437
|
-
const runner = new PipelineRunner(ctx);
|
|
11052
|
+
const runner = new PipelineRunner(ctx, protectionHooks(false, respectful));
|
|
10438
11053
|
const result = await runner.run(session.page, pipeline);
|
|
10439
11054
|
if (result.ok) {
|
|
10440
11055
|
const blockResult = await detectBlock(session.page);
|
|
@@ -10461,7 +11076,7 @@ var init_pipeline_executor = __esm({
|
|
|
10461
11076
|
return result;
|
|
10462
11077
|
}
|
|
10463
11078
|
/** Execute via local Chrome daemon (headless + binary-headful). */
|
|
10464
|
-
async executeLocal(userId, token, pipeline, mode, instanceId = DEFAULT_INSTANCE) {
|
|
11079
|
+
async executeLocal(userId, token, pipeline, mode, instanceId = DEFAULT_INSTANCE, respectful = false) {
|
|
10465
11080
|
const startTime = Date.now();
|
|
10466
11081
|
let acquired = false;
|
|
10467
11082
|
try {
|
|
@@ -10483,8 +11098,38 @@ var init_pipeline_executor = __esm({
|
|
|
10483
11098
|
const ctx = this.deps.refStore.makeContext(userId, token);
|
|
10484
11099
|
if (sessionData) ctx.sessionData = sessionData;
|
|
10485
11100
|
if (this.pendingElicitOtp) ctx.elicitOtp = this.pendingElicitOtp;
|
|
10486
|
-
const runner = new PipelineRunner(ctx);
|
|
10487
|
-
|
|
11101
|
+
const runner = new PipelineRunner(ctx, protectionHooks(mode === "headless", respectful));
|
|
11102
|
+
let result;
|
|
11103
|
+
try {
|
|
11104
|
+
result = await runner.run(page, pipeline);
|
|
11105
|
+
} catch (err) {
|
|
11106
|
+
if (!(err instanceof HeadedRequiredError)) throw err;
|
|
11107
|
+
this.deps.daemon.release(mode, instanceId);
|
|
11108
|
+
acquired = false;
|
|
11109
|
+
await this.deps.daemon.stopMode(mode, instanceId);
|
|
11110
|
+
const available = this.deps.availableModes();
|
|
11111
|
+
const headed = available.includes("binary-headful") ? "binary-headful" : "docker-headful";
|
|
11112
|
+
if (!available.includes(headed)) throw new Error("A headed browser is required but unavailable.");
|
|
11113
|
+
await new Promise((resolve) => setTimeout(resolve, PROTECTED_INTERVAL_MS));
|
|
11114
|
+
const index = err.stepIndex;
|
|
11115
|
+
const remaining = pipeline.steps.slice(index);
|
|
11116
|
+
const insertedNavigation = remaining[0]?.type !== "navigate";
|
|
11117
|
+
if (insertedNavigation) remaining.unshift({ type: "navigate", url: err.url });
|
|
11118
|
+
const resumed = await this.executeWithMode(userId, token, { ...pipeline, steps: remaining }, headed, instanceId, true);
|
|
11119
|
+
const offset = index - Number(insertedNavigation);
|
|
11120
|
+
resumed.results = [...err.results, ...resumed.results.filter((r) => !insertedNavigation || r.stepIndex > 0).map((r) => ({ ...r, stepIndex: r.stepIndex + offset }))];
|
|
11121
|
+
resumed.completedSteps = index + Math.max(0, resumed.completedSteps - Number(insertedNavigation));
|
|
11122
|
+
resumed.totalSteps = pipeline.steps.length;
|
|
11123
|
+
resumed.obstacles = [...err.obstacles, ...resumed.obstacles];
|
|
11124
|
+
if (resumed.error) {
|
|
11125
|
+
resumed.error.failedAtStep = Math.max(index, resumed.error.failedAtStep + offset);
|
|
11126
|
+
resumed.error.failedStep = pipeline.steps[resumed.error.failedAtStep];
|
|
11127
|
+
}
|
|
11128
|
+
resumed.durationMs = Date.now() - startTime;
|
|
11129
|
+
resumed.modeEscalated = true;
|
|
11130
|
+
resumed.modeUsed = headed;
|
|
11131
|
+
return resumed;
|
|
11132
|
+
}
|
|
10488
11133
|
if (result.ok) {
|
|
10489
11134
|
const blockResult = await detectBlock(page);
|
|
10490
11135
|
if (blockResult.blocked) {
|
|
@@ -10557,6 +11202,8 @@ var FetchService;
|
|
|
10557
11202
|
var init_fetch_service = __esm({
|
|
10558
11203
|
"src/lib/execution/fetch-service.ts"() {
|
|
10559
11204
|
"use strict";
|
|
11205
|
+
init_site_protection();
|
|
11206
|
+
init_launcher();
|
|
10560
11207
|
init_launcher();
|
|
10561
11208
|
init_stealth();
|
|
10562
11209
|
init_humanize();
|
|
@@ -10587,12 +11234,30 @@ var init_fetch_service = __esm({
|
|
|
10587
11234
|
const { browser, name: browserName } = await getBrowserWithFallback(preferredBrowser);
|
|
10588
11235
|
context = await browser.newContext(stealthContextOptions({ locale, extraHTTPHeaders: { ...headers } }, userId ?? void 0));
|
|
10589
11236
|
if (sessionData) await injectCookies(context, sessionData);
|
|
10590
|
-
|
|
11237
|
+
let page = await context.newPage();
|
|
10591
11238
|
await applyStealthToPage(page);
|
|
10592
11239
|
await page.goto(url, { waitUntil: waitUntil || "domcontentloaded", timeout: TIMEOUTS.NAVIGATION });
|
|
11240
|
+
let protection = false;
|
|
11241
|
+
const pivotIfDetected = async () => {
|
|
11242
|
+
if (protection || !await detectSiteProtection(page)) return;
|
|
11243
|
+
protection = true;
|
|
11244
|
+
const targetUrl = page.url();
|
|
11245
|
+
const cookies = await context.cookies();
|
|
11246
|
+
await context.close();
|
|
11247
|
+
context = await (await getHeadedBrowser()).newContext(stealthContextOptions({ locale, extraHTTPHeaders: { ...headers } }, userId ?? void 0));
|
|
11248
|
+
await context.addCookies(cookies);
|
|
11249
|
+
page = await context.newPage();
|
|
11250
|
+
await applyStealthToPage(page);
|
|
11251
|
+
await new Promise((resolve) => setTimeout(resolve, PROTECTED_INTERVAL_MS));
|
|
11252
|
+
await paceProtectedDomain(new URL(targetUrl).hostname);
|
|
11253
|
+
await page.goto(targetUrl, { waitUntil, timeout: TIMEOUTS.NAVIGATION });
|
|
11254
|
+
};
|
|
11255
|
+
await pivotIfDetected();
|
|
10593
11256
|
if (sessionData) await injectStorage(page, sessionData);
|
|
10594
11257
|
if (waitForSelector) await page.waitForSelector(waitForSelector, { timeout: TIMEOUTS.SELECTOR_WAIT });
|
|
10595
11258
|
for (const action of actions) {
|
|
11259
|
+
await pivotIfDetected();
|
|
11260
|
+
if (protection) await paceProtectedDomain(new URL(page.url()).hostname);
|
|
10596
11261
|
switch (action.type) {
|
|
10597
11262
|
case "click":
|
|
10598
11263
|
await page.click(action.selector);
|
|
@@ -10623,6 +11288,7 @@ var init_fetch_service = __esm({
|
|
|
10623
11288
|
break;
|
|
10624
11289
|
}
|
|
10625
11290
|
}
|
|
11291
|
+
await pivotIfDetected();
|
|
10626
11292
|
const finalUrl = page.url();
|
|
10627
11293
|
const html = returnHtml ? await page.content() : void 0;
|
|
10628
11294
|
const result = extract2 ? await page.evaluate(extract2) : void 0;
|
|
@@ -10979,6 +11645,11 @@ var init_iframer = __esm({
|
|
|
10979
11645
|
listInstances() {
|
|
10980
11646
|
return this.daemon.instancesInfo();
|
|
10981
11647
|
}
|
|
11648
|
+
/** Close browsers that never navigated anywhere. See
|
|
11649
|
+
* BrowserDaemon.sweepBlankInstances — called from the server's reap tick. */
|
|
11650
|
+
sweepBlankBrowsers() {
|
|
11651
|
+
return this.daemon.sweepBlankInstances();
|
|
11652
|
+
}
|
|
10982
11653
|
/** Kill all browser instances and reset state. Next execute call will
|
|
10983
11654
|
* launch a fresh browser automatically — no manual restart needed. */
|
|
10984
11655
|
async restartBrowser() {
|
|
@@ -11118,6 +11789,13 @@ ${api.domain} (${api.baseUrl})`);
|
|
|
11118
11789
|
}
|
|
11119
11790
|
}
|
|
11120
11791
|
}
|
|
11792
|
+
const serverErrors = (data.results || []).flatMap((r) => r.serverErrors || []);
|
|
11793
|
+
if (serverErrors.length > 0) {
|
|
11794
|
+
lines.push("\n--- Server errors (HTTP >= 400) ---");
|
|
11795
|
+
for (const se of serverErrors) {
|
|
11796
|
+
lines.push(` [step ${se.stepIndex}] ${se.method} ${se.url} \u2192 ${se.status}${se.body ? `: ${se.body}` : ""}`);
|
|
11797
|
+
}
|
|
11798
|
+
}
|
|
11121
11799
|
if (data.error) {
|
|
11122
11800
|
lines.push("\n--- Failure ---");
|
|
11123
11801
|
if (typeof data.error === "string") {
|
|
@@ -11128,6 +11806,10 @@ ${api.domain} (${api.baseUrl})`);
|
|
|
11128
11806
|
lines.push(`Message: ${data.error.message}`);
|
|
11129
11807
|
lines.push(`Retryable: ${data.error.retryable}`);
|
|
11130
11808
|
if (data.error.suggestion) lines.push(`Suggestion: ${data.error.suggestion}`);
|
|
11809
|
+
const atFailure = serverErrors.filter((se) => se.stepIndex === data.error?.failedAtStep);
|
|
11810
|
+
for (const se of atFailure) {
|
|
11811
|
+
lines.push(`Server responded: ${se.method} ${se.url} \u2192 ${se.status}${se.body ? `: ${se.body}` : ""}`);
|
|
11812
|
+
}
|
|
11131
11813
|
if (data.error.pageState?.url) lines.push(`URL at failure: ${data.error.pageState.url}`);
|
|
11132
11814
|
}
|
|
11133
11815
|
}
|
|
@@ -11544,6 +12226,21 @@ function parseFlag(args2, flag, hasValue = true) {
|
|
|
11544
12226
|
function hasFlag(args2, flag) {
|
|
11545
12227
|
return args2.includes(flag);
|
|
11546
12228
|
}
|
|
12229
|
+
var LAUNCH_MODES = ["headless", "binary-headful", "docker-headful"];
|
|
12230
|
+
function parseModeFlag(args2) {
|
|
12231
|
+
const mode = parseFlag(args2, "--mode");
|
|
12232
|
+
if (!mode) return void 0;
|
|
12233
|
+
if (LAUNCH_MODES.includes(mode)) return mode;
|
|
12234
|
+
if (mode === "extension") {
|
|
12235
|
+
console.error("\n --mode extension is not available from the CLI: it needs a tab id.");
|
|
12236
|
+
console.error(" Use the `tabs` tool from your agent, or POST /extension/execute with a tabId.\n");
|
|
12237
|
+
process.exit(1);
|
|
12238
|
+
}
|
|
12239
|
+
console.error(`
|
|
12240
|
+
Unknown --mode "${mode}". Use one of: ${LAUNCH_MODES.join(", ")}.
|
|
12241
|
+
`);
|
|
12242
|
+
process.exit(1);
|
|
12243
|
+
}
|
|
11547
12244
|
function handleResponse(data, screenshotPath) {
|
|
11548
12245
|
const { screenshot: screenshot2, tileScreenshots, ...rest } = data;
|
|
11549
12246
|
if (screenshot2 && screenshotPath) {
|
|
@@ -12018,7 +12715,7 @@ async function main() {
|
|
|
12018
12715
|
process.exit(1);
|
|
12019
12716
|
}
|
|
12020
12717
|
const options = { ...inputOptions };
|
|
12021
|
-
const mode =
|
|
12718
|
+
const mode = parseModeFlag(args);
|
|
12022
12719
|
if (mode) options.mode = mode;
|
|
12023
12720
|
if (hasFlag(args, "--capture-api")) options.captureApi = true;
|
|
12024
12721
|
if (hasFlag(args, "--continue-on-error")) options.continueOnError = true;
|
|
@@ -12103,7 +12800,7 @@ Screenshot: ${shot}`);
|
|
|
12103
12800
|
const url = args[0];
|
|
12104
12801
|
const outPath = parseFlag(args, "--output") || parseFlag(args, "-o") || "/tmp/iframer-screenshot.jpg";
|
|
12105
12802
|
if (url && url.startsWith("http")) {
|
|
12106
|
-
const mode =
|
|
12803
|
+
const mode = parseModeFlag(args) || "headless";
|
|
12107
12804
|
const annotate = hasFlag(args, "--annotate");
|
|
12108
12805
|
const docker = await isDockerRunning();
|
|
12109
12806
|
const steps = [
|
|
@@ -12334,7 +13031,7 @@ Screenshot: ${shot}`);
|
|
|
12334
13031
|
process.exit(1);
|
|
12335
13032
|
}
|
|
12336
13033
|
const options = { captureApi: true };
|
|
12337
|
-
const mode =
|
|
13034
|
+
const mode = parseModeFlag(args);
|
|
12338
13035
|
if (mode) options.mode = mode;
|
|
12339
13036
|
const docker = await isDockerRunning();
|
|
12340
13037
|
let result;
|