iframer-toolkit 0.10.6 → 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/dist/cli.js +642 -71
- package/dist/local-server.cjs +825 -296
- package/dist/mcp-server.cjs +33 -5
- package/extension/manifest.json +1 -1
- package/package.json +1 -1
- package/skills/iframer.md +61 -7
package/dist/local-server.cjs
CHANGED
|
@@ -35,6 +35,23 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
|
|
|
35
35
|
mod
|
|
36
36
|
));
|
|
37
37
|
|
|
38
|
+
// src/lib/paths.ts
|
|
39
|
+
var paths_exports = {};
|
|
40
|
+
__export(paths_exports, {
|
|
41
|
+
getDataDir: () => getDataDir
|
|
42
|
+
});
|
|
43
|
+
function getDataDir() {
|
|
44
|
+
return process.env.IFRAMER_DATA_DIR || import_path.default.join(import_os.default.homedir(), ".iframer");
|
|
45
|
+
}
|
|
46
|
+
var import_path, import_os;
|
|
47
|
+
var init_paths = __esm({
|
|
48
|
+
"src/lib/paths.ts"() {
|
|
49
|
+
"use strict";
|
|
50
|
+
import_path = __toESM(require("path"));
|
|
51
|
+
import_os = __toESM(require("os"));
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
38
55
|
// src/lib/session/persistence.ts
|
|
39
56
|
var persistence_exports = {};
|
|
40
57
|
__export(persistence_exports, {
|
|
@@ -96,23 +113,6 @@ var init_persistence = __esm({
|
|
|
96
113
|
}
|
|
97
114
|
});
|
|
98
115
|
|
|
99
|
-
// src/lib/paths.ts
|
|
100
|
-
var paths_exports = {};
|
|
101
|
-
__export(paths_exports, {
|
|
102
|
-
getDataDir: () => getDataDir
|
|
103
|
-
});
|
|
104
|
-
function getDataDir() {
|
|
105
|
-
return process.env.IFRAMER_DATA_DIR || import_path.default.join(import_os.default.homedir(), ".iframer");
|
|
106
|
-
}
|
|
107
|
-
var import_path, import_os;
|
|
108
|
-
var init_paths = __esm({
|
|
109
|
-
"src/lib/paths.ts"() {
|
|
110
|
-
"use strict";
|
|
111
|
-
import_path = __toESM(require("path"));
|
|
112
|
-
import_os = __toESM(require("os"));
|
|
113
|
-
}
|
|
114
|
-
});
|
|
115
|
-
|
|
116
116
|
// index.ts
|
|
117
117
|
var import_express = __toESM(require("express"));
|
|
118
118
|
var import_path12 = __toESM(require("path"));
|
|
@@ -127,11 +127,11 @@ var import_path11 = __toESM(require("path"));
|
|
|
127
127
|
var import_url = require("url");
|
|
128
128
|
|
|
129
129
|
// src/lib/browser/session-manager.ts
|
|
130
|
-
var
|
|
131
|
-
var
|
|
130
|
+
var import_child_process2 = require("child_process");
|
|
131
|
+
var import_fs3 = __toESM(require("fs"));
|
|
132
132
|
|
|
133
133
|
// src/lib/browser/launcher.ts
|
|
134
|
-
var
|
|
134
|
+
var import_fs2 = __toESM(require("fs"));
|
|
135
135
|
var import_patchright = require("patchright");
|
|
136
136
|
|
|
137
137
|
// src/lib/browser/stealth.ts
|
|
@@ -542,27 +542,213 @@ function createLogger(tag) {
|
|
|
542
542
|
};
|
|
543
543
|
}
|
|
544
544
|
|
|
545
|
+
// src/lib/browser/registry.ts
|
|
546
|
+
var import_fs = __toESM(require("fs"));
|
|
547
|
+
var import_path2 = __toESM(require("path"));
|
|
548
|
+
var import_child_process = require("child_process");
|
|
549
|
+
init_paths();
|
|
550
|
+
var log = createLogger("registry");
|
|
551
|
+
function browsersDir() {
|
|
552
|
+
const dir = import_path2.default.join(getDataDir(), "browsers");
|
|
553
|
+
import_fs.default.mkdirSync(dir, { recursive: true });
|
|
554
|
+
return dir;
|
|
555
|
+
}
|
|
556
|
+
function serverInfoPath() {
|
|
557
|
+
return import_path2.default.join(getDataDir(), "server.json");
|
|
558
|
+
}
|
|
559
|
+
function isPidAlive(pid) {
|
|
560
|
+
if (!Number.isInteger(pid) || pid <= 1) return false;
|
|
561
|
+
try {
|
|
562
|
+
process.kill(pid, 0);
|
|
563
|
+
return true;
|
|
564
|
+
} catch {
|
|
565
|
+
return false;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
function pidMatchesMarker(pid, marker) {
|
|
569
|
+
if (!isPidAlive(pid)) return false;
|
|
570
|
+
try {
|
|
571
|
+
const cmd = (0, import_child_process.execSync)(`ps -o command= -p ${pid}`, { encoding: "utf8" });
|
|
572
|
+
return cmd.includes(marker);
|
|
573
|
+
} catch {
|
|
574
|
+
return false;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
function findChromePidByMarker(marker) {
|
|
578
|
+
try {
|
|
579
|
+
const out = (0, import_child_process.execSync)(`pgrep -f -- "${marker}"`, { encoding: "utf8" }).trim();
|
|
580
|
+
const pids = out.split("\n").map((s) => parseInt(s, 10)).filter((n) => Number.isInteger(n) && n !== process.pid);
|
|
581
|
+
if (pids.length === 0) return null;
|
|
582
|
+
return Math.min(...pids);
|
|
583
|
+
} catch {
|
|
584
|
+
return null;
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
function registerBrowser(rec) {
|
|
588
|
+
try {
|
|
589
|
+
import_fs.default.writeFileSync(import_path2.default.join(browsersDir(), `${rec.chromePid}.json`), JSON.stringify(rec, null, 2));
|
|
590
|
+
} catch (err) {
|
|
591
|
+
log.warn(`failed to write browser record for pid ${rec.chromePid}: ${err}`);
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
function unregisterBrowser(chromePid) {
|
|
595
|
+
try {
|
|
596
|
+
import_fs.default.unlinkSync(import_path2.default.join(browsersDir(), `${chromePid}.json`));
|
|
597
|
+
} catch {
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
601
|
+
async function forceKillBrowser(rec) {
|
|
602
|
+
if (!isPidAlive(rec.chromePid)) return true;
|
|
603
|
+
if (!pidMatchesMarker(rec.chromePid, rec.marker)) {
|
|
604
|
+
return true;
|
|
605
|
+
}
|
|
606
|
+
try {
|
|
607
|
+
process.kill(rec.chromePid, "SIGKILL");
|
|
608
|
+
} catch {
|
|
609
|
+
}
|
|
610
|
+
const deadline = Date.now() + 2e3;
|
|
611
|
+
while (Date.now() < deadline) {
|
|
612
|
+
if (!isPidAlive(rec.chromePid)) return true;
|
|
613
|
+
await sleep(100);
|
|
614
|
+
}
|
|
615
|
+
return !isPidAlive(rec.chromePid);
|
|
616
|
+
}
|
|
617
|
+
async function reapOrphanBrowsers() {
|
|
618
|
+
let reaped = 0;
|
|
619
|
+
let skipped = 0;
|
|
620
|
+
let files = [];
|
|
621
|
+
try {
|
|
622
|
+
files = import_fs.default.readdirSync(browsersDir()).filter((f) => f.endsWith(".json"));
|
|
623
|
+
} catch {
|
|
624
|
+
return { reaped, skipped };
|
|
625
|
+
}
|
|
626
|
+
for (const file of files) {
|
|
627
|
+
const full = import_path2.default.join(browsersDir(), file);
|
|
628
|
+
let rec;
|
|
629
|
+
try {
|
|
630
|
+
rec = JSON.parse(import_fs.default.readFileSync(full, "utf8"));
|
|
631
|
+
} catch {
|
|
632
|
+
try {
|
|
633
|
+
import_fs.default.unlinkSync(full);
|
|
634
|
+
} catch {
|
|
635
|
+
}
|
|
636
|
+
continue;
|
|
637
|
+
}
|
|
638
|
+
if (!isPidAlive(rec.chromePid) || !pidMatchesMarker(rec.chromePid, rec.marker)) {
|
|
639
|
+
try {
|
|
640
|
+
import_fs.default.unlinkSync(full);
|
|
641
|
+
} catch {
|
|
642
|
+
}
|
|
643
|
+
continue;
|
|
644
|
+
}
|
|
645
|
+
if (isPidAlive(rec.ownerPid)) {
|
|
646
|
+
skipped++;
|
|
647
|
+
continue;
|
|
648
|
+
}
|
|
649
|
+
log.info(`reaping orphan Chrome pid=${rec.chromePid} (${rec.key}), owner ${rec.ownerPid} is dead`);
|
|
650
|
+
if (await forceKillBrowser(rec)) {
|
|
651
|
+
try {
|
|
652
|
+
import_fs.default.unlinkSync(full);
|
|
653
|
+
} catch {
|
|
654
|
+
}
|
|
655
|
+
reaped++;
|
|
656
|
+
} else {
|
|
657
|
+
log.warn(`failed to kill orphan Chrome pid=${rec.chromePid} \u2014 leaving record for next sweep`);
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
return { reaped, skipped };
|
|
661
|
+
}
|
|
662
|
+
function writeServerInfo(info) {
|
|
663
|
+
import_fs.default.writeFileSync(serverInfoPath(), JSON.stringify(info, null, 2));
|
|
664
|
+
}
|
|
665
|
+
function readServerInfo() {
|
|
666
|
+
try {
|
|
667
|
+
const info = JSON.parse(import_fs.default.readFileSync(serverInfoPath(), "utf8"));
|
|
668
|
+
if (!Number.isInteger(info.pid) || !Number.isInteger(info.port)) return null;
|
|
669
|
+
return info;
|
|
670
|
+
} catch {
|
|
671
|
+
return null;
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
function clearServerInfo(pid) {
|
|
675
|
+
const info = readServerInfo();
|
|
676
|
+
if (info && info.pid === pid) {
|
|
677
|
+
try {
|
|
678
|
+
import_fs.default.unlinkSync(serverInfoPath());
|
|
679
|
+
} catch {
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
545
684
|
// src/lib/browser/launcher.ts
|
|
546
|
-
var
|
|
685
|
+
var log2 = createLogger("launcher");
|
|
547
686
|
var UBLOCK_PATH = "/extensions/uBlock0.chromium";
|
|
548
687
|
function findChromeExecutable() {
|
|
549
688
|
if (process.env.CHROME_EXECUTABLE) return process.env.CHROME_EXECUTABLE;
|
|
550
|
-
if (
|
|
689
|
+
if (import_fs2.default.existsSync("/usr/bin/google-chrome-stable")) return "/usr/bin/google-chrome-stable";
|
|
551
690
|
return void 0;
|
|
552
691
|
}
|
|
553
692
|
var cachedBrowser = null;
|
|
554
693
|
var headedBrowser = null;
|
|
694
|
+
var headedPid = null;
|
|
695
|
+
var headedIdleTimer = null;
|
|
696
|
+
var HEADED_IDLE_MS = 5 * 60 * 1e3;
|
|
697
|
+
function armHeadedIdleTimer() {
|
|
698
|
+
if (headedIdleTimer) clearTimeout(headedIdleTimer);
|
|
699
|
+
headedIdleTimer = setTimeout(() => {
|
|
700
|
+
if (!headedBrowser) return;
|
|
701
|
+
const pages = headedBrowser.contexts().reduce((n, c) => n + c.pages().length, 0);
|
|
702
|
+
if (pages > 0) {
|
|
703
|
+
armHeadedIdleTimer();
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
706
|
+
log2.info("idle timeout for the pivot headed browser, closing");
|
|
707
|
+
void closeHeadedBrowser();
|
|
708
|
+
}, HEADED_IDLE_MS);
|
|
709
|
+
}
|
|
555
710
|
async function getHeadedBrowser() {
|
|
556
|
-
if (!headedBrowser?.isConnected())
|
|
711
|
+
if (!headedBrowser?.isConnected()) {
|
|
712
|
+
const marker = `--iframer-key=pivot-headed-${process.pid}-${Date.now()}`;
|
|
713
|
+
headedBrowser = await import_patchright.chromium.launch({ headless: false, args: [...STEALTH_ARGS, marker] });
|
|
714
|
+
headedPid = findChromePidByMarker(marker);
|
|
715
|
+
if (headedPid) {
|
|
716
|
+
registerBrowser({
|
|
717
|
+
key: "pivot::headed",
|
|
718
|
+
chromePid: headedPid,
|
|
719
|
+
ownerPid: process.pid,
|
|
720
|
+
marker,
|
|
721
|
+
launchedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
722
|
+
});
|
|
723
|
+
} else {
|
|
724
|
+
log2.warn("could not resolve Chrome PID for the pivot headed browser \u2014 force-kill unavailable");
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
armHeadedIdleTimer();
|
|
557
728
|
return headedBrowser;
|
|
558
729
|
}
|
|
730
|
+
async function closeHeadedBrowser() {
|
|
731
|
+
if (headedIdleTimer) {
|
|
732
|
+
clearTimeout(headedIdleTimer);
|
|
733
|
+
headedIdleTimer = null;
|
|
734
|
+
}
|
|
735
|
+
if (headedBrowser) {
|
|
736
|
+
await headedBrowser.close().catch(() => {
|
|
737
|
+
});
|
|
738
|
+
headedBrowser = null;
|
|
739
|
+
}
|
|
740
|
+
if (headedPid !== null) {
|
|
741
|
+
unregisterBrowser(headedPid);
|
|
742
|
+
headedPid = null;
|
|
743
|
+
}
|
|
744
|
+
}
|
|
559
745
|
async function getBrowser(_name = "chromium") {
|
|
560
746
|
if (cachedBrowser) {
|
|
561
747
|
if (cachedBrowser.isConnected()) return cachedBrowser;
|
|
562
748
|
try {
|
|
563
749
|
await cachedBrowser.close();
|
|
564
750
|
} catch (e) {
|
|
565
|
-
|
|
751
|
+
log2.warn(`stale browser close failed: ${e}`);
|
|
566
752
|
}
|
|
567
753
|
cachedBrowser = null;
|
|
568
754
|
}
|
|
@@ -573,16 +759,12 @@ async function getBrowser(_name = "chromium") {
|
|
|
573
759
|
return cachedBrowser;
|
|
574
760
|
}
|
|
575
761
|
async function closeBrowser() {
|
|
576
|
-
|
|
577
|
-
await headedBrowser.close().catch(() => {
|
|
578
|
-
});
|
|
579
|
-
headedBrowser = null;
|
|
580
|
-
}
|
|
762
|
+
await closeHeadedBrowser();
|
|
581
763
|
if (!cachedBrowser) return;
|
|
582
764
|
try {
|
|
583
765
|
await cachedBrowser.close();
|
|
584
766
|
} catch (e) {
|
|
585
|
-
|
|
767
|
+
log2.warn(`closeBrowser failed: ${e}`);
|
|
586
768
|
}
|
|
587
769
|
cachedBrowser = null;
|
|
588
770
|
}
|
|
@@ -591,7 +773,7 @@ async function getBrowserWithFallback(_preferred) {
|
|
|
591
773
|
}
|
|
592
774
|
async function launchHeadful(displayNum) {
|
|
593
775
|
const executablePath = findChromeExecutable();
|
|
594
|
-
const hasExtensions =
|
|
776
|
+
const hasExtensions = import_fs2.default.existsSync(UBLOCK_PATH);
|
|
595
777
|
const args = [
|
|
596
778
|
"--no-sandbox",
|
|
597
779
|
"--disable-setuid-sandbox",
|
|
@@ -611,7 +793,7 @@ async function launchHeadful(displayNum) {
|
|
|
611
793
|
env: { ...process.env, DISPLAY: `:${displayNum}` }
|
|
612
794
|
};
|
|
613
795
|
if (executablePath) launchOpts.executablePath = executablePath;
|
|
614
|
-
|
|
796
|
+
log2.debug(`headful: ${executablePath || "patchright chromium"}, extensions: ${hasExtensions}`);
|
|
615
797
|
return import_patchright.chromium.launch(launchOpts);
|
|
616
798
|
}
|
|
617
799
|
|
|
@@ -668,7 +850,13 @@ var TIMING = {
|
|
|
668
850
|
/** Scroll step delay */
|
|
669
851
|
SCROLL_DELAY: 150,
|
|
670
852
|
/** Stale state check interval */
|
|
671
|
-
STALE_CHECK_INTERVAL: 2e3
|
|
853
|
+
STALE_CHECK_INTERVAL: 2e3,
|
|
854
|
+
/** captureApi: grace period after the last step for a submit's XHR to fire */
|
|
855
|
+
CAPTURE_SETTLE: 300,
|
|
856
|
+
/** captureApi: max wait for in-flight requests to answer before stopping */
|
|
857
|
+
CAPTURE_PENDING_MAX: 3e3,
|
|
858
|
+
/** Post-failure probe ("does the selector exist right now?") budget */
|
|
859
|
+
FAILURE_PROBE: 3e3
|
|
672
860
|
};
|
|
673
861
|
var CAPTCHA_GRID = {
|
|
674
862
|
/** Pixels from bframe top to grid start (reCAPTCHA) */
|
|
@@ -706,6 +894,10 @@ var TIMEOUTS = {
|
|
|
706
894
|
DEFAULT_STALE: 2e4,
|
|
707
895
|
NAVIGATION: 6e4,
|
|
708
896
|
SELECTOR_WAIT: 1e4,
|
|
897
|
+
// Actionability wait for click. Under the 20s stale timer on purpose, so
|
|
898
|
+
// Playwright's own reason (not visible, covered by another element…) reaches
|
|
899
|
+
// the agent instead of a generic "no state change".
|
|
900
|
+
CLICK: 8e3,
|
|
709
901
|
TOTP_INPUT: 5e3,
|
|
710
902
|
API_REQUEST: 18e4,
|
|
711
903
|
HEALTH_CHECK: 3e3,
|
|
@@ -752,7 +944,7 @@ function generateWindowsFingerprint() {
|
|
|
752
944
|
}
|
|
753
945
|
|
|
754
946
|
// src/lib/browser/session-manager.ts
|
|
755
|
-
var
|
|
947
|
+
var log3 = createLogger("session");
|
|
756
948
|
var BASE_DISPLAY = parseInt(process.env.VNC_BASE_DISPLAY || "99", 10);
|
|
757
949
|
var MAX_SESSIONS = parseInt(process.env.VNC_MAX_SESSIONS || "20", 10);
|
|
758
950
|
var SESSION_TIMEOUT = parseInt(process.env.VNC_SESSION_TIMEOUT_MS || "300000", 10);
|
|
@@ -776,7 +968,7 @@ function waitForSocket(displayNum, timeoutMs = 5e3) {
|
|
|
776
968
|
return new Promise((resolve, reject) => {
|
|
777
969
|
const start = Date.now();
|
|
778
970
|
const check = () => {
|
|
779
|
-
if (
|
|
971
|
+
if (import_fs3.default.existsSync(socketPath)) return resolve();
|
|
780
972
|
if (Date.now() - start > timeoutMs) return reject(new Error(`Xvfb socket not ready after ${timeoutMs}ms`));
|
|
781
973
|
setTimeout(check, 100);
|
|
782
974
|
};
|
|
@@ -798,17 +990,17 @@ async function startSession(userId) {
|
|
|
798
990
|
const displayNum = allocateDisplay();
|
|
799
991
|
const vncPort = 5900 + displayNum;
|
|
800
992
|
const wsPort = 6080 + (displayNum - BASE_DISPLAY);
|
|
801
|
-
const xvfb = (0,
|
|
993
|
+
const xvfb = (0, import_child_process2.spawn)("Xvfb", [`:${displayNum}`, "-screen", "0", "1920x1080x24", "-ac"], {
|
|
802
994
|
stdio: "ignore"
|
|
803
995
|
});
|
|
804
996
|
await waitForSocket(displayNum);
|
|
805
|
-
const x11vnc = (0,
|
|
997
|
+
const x11vnc = (0, import_child_process2.spawn)(
|
|
806
998
|
"x11vnc",
|
|
807
999
|
["-display", `:${displayNum}`, "-nopw", "-listen", "localhost", "-rfbport", String(vncPort), "-shared", "-forever"],
|
|
808
1000
|
{ stdio: "ignore" }
|
|
809
1001
|
);
|
|
810
|
-
const noVncPath =
|
|
811
|
-
const websockify = (0,
|
|
1002
|
+
const noVncPath = import_fs3.default.existsSync("/usr/share/novnc") ? "/usr/share/novnc" : "/usr/share/noVNC";
|
|
1003
|
+
const websockify = (0, import_child_process2.spawn)("websockify", ["--web", noVncPath, String(wsPort), `localhost:${vncPort}`], {
|
|
812
1004
|
stdio: "ignore"
|
|
813
1005
|
});
|
|
814
1006
|
await new Promise((r) => setTimeout(r, 500));
|
|
@@ -819,7 +1011,7 @@ async function startSession(userId) {
|
|
|
819
1011
|
const stealthScript = buildStealthScript(fingerprint);
|
|
820
1012
|
contextStealthScripts.set(context, stealthScript);
|
|
821
1013
|
const page = await context.newPage();
|
|
822
|
-
|
|
1014
|
+
log3.debug(`fingerprint: ${fingerprint.userAgent.slice(0, 60)}... DPR=${fingerprint.deviceScaleFactor} screen=${fingerprint.screenWidth}x${fingerprint.screenHeight}`);
|
|
823
1015
|
const session = {
|
|
824
1016
|
displayNum,
|
|
825
1017
|
vncPort,
|
|
@@ -871,7 +1063,7 @@ async function stopSession(userId) {
|
|
|
871
1063
|
killProcess(session.xvfb);
|
|
872
1064
|
await new Promise((r) => setTimeout(r, 1e3));
|
|
873
1065
|
try {
|
|
874
|
-
|
|
1066
|
+
import_fs3.default.unlinkSync(`/tmp/.X11-unix/X${session.displayNum}`);
|
|
875
1067
|
} catch {
|
|
876
1068
|
}
|
|
877
1069
|
freeDisplay(session.displayNum);
|
|
@@ -885,9 +1077,9 @@ async function cleanupAllSessions() {
|
|
|
885
1077
|
|
|
886
1078
|
// src/lib/auth/crypto.ts
|
|
887
1079
|
var import_crypto = __toESM(require("crypto"));
|
|
888
|
-
var
|
|
1080
|
+
var import_fs4 = __toESM(require("fs"));
|
|
889
1081
|
var import_os2 = __toESM(require("os"));
|
|
890
|
-
var
|
|
1082
|
+
var import_path3 = __toESM(require("path"));
|
|
891
1083
|
init_paths();
|
|
892
1084
|
var SALT = "iframer-session";
|
|
893
1085
|
var INFO = "encryption";
|
|
@@ -897,21 +1089,21 @@ var TAG_LENGTH = 16;
|
|
|
897
1089
|
function getLocalToken() {
|
|
898
1090
|
if (process.env.IFRAMER_SECRET) return process.env.IFRAMER_SECRET;
|
|
899
1091
|
const candidates = [
|
|
900
|
-
|
|
901
|
-
|
|
1092
|
+
import_path3.default.join(getDataDir(), "secret"),
|
|
1093
|
+
import_path3.default.join(process.env.XDG_RUNTIME_DIR || import_os2.default.tmpdir(), "iframer-secret")
|
|
902
1094
|
];
|
|
903
1095
|
for (const file of candidates) {
|
|
904
1096
|
try {
|
|
905
|
-
const existing =
|
|
1097
|
+
const existing = import_fs4.default.readFileSync(file, "utf8").trim();
|
|
906
1098
|
if (existing) return existing;
|
|
907
1099
|
} catch {
|
|
908
1100
|
}
|
|
909
1101
|
}
|
|
910
1102
|
for (const file of candidates) {
|
|
911
1103
|
try {
|
|
912
|
-
|
|
1104
|
+
import_fs4.default.mkdirSync(import_path3.default.dirname(file), { recursive: true });
|
|
913
1105
|
const secret = import_crypto.default.randomBytes(32).toString("hex");
|
|
914
|
-
|
|
1106
|
+
import_fs4.default.writeFileSync(file, secret, { mode: 384 });
|
|
915
1107
|
return secret;
|
|
916
1108
|
} catch {
|
|
917
1109
|
}
|
|
@@ -971,17 +1163,17 @@ function generateTOTP(secret, period = 30, digits = 6) {
|
|
|
971
1163
|
init_persistence();
|
|
972
1164
|
|
|
973
1165
|
// src/lib/screenshot.ts
|
|
974
|
-
var
|
|
975
|
-
var
|
|
976
|
-
var
|
|
1166
|
+
var import_fs5 = __toESM(require("fs"));
|
|
1167
|
+
var import_path4 = __toESM(require("path"));
|
|
1168
|
+
var log4 = createLogger("screenshot");
|
|
977
1169
|
var MAX_AGE_MS = parseInt(process.env.IFRAMER_SCREENSHOT_MAX_AGE_MS || String(24 * 60 * 60 * 1e3), 10);
|
|
978
1170
|
var MAX_FILES = parseInt(process.env.IFRAMER_SCREENSHOT_MAX_FILES || "500", 10);
|
|
979
1171
|
var PRUNE_THROTTLE_MS = 5 * 60 * 1e3;
|
|
980
1172
|
var lastPruneAt = 0;
|
|
981
1173
|
function saveScreenshot(buffer, filename, screenshotDir, publicUrl) {
|
|
982
|
-
|
|
983
|
-
const filePath =
|
|
984
|
-
|
|
1174
|
+
import_fs5.default.mkdirSync(screenshotDir, { recursive: true });
|
|
1175
|
+
const filePath = import_path4.default.join(screenshotDir, filename);
|
|
1176
|
+
import_fs5.default.writeFileSync(filePath, buffer);
|
|
985
1177
|
maybePrune(screenshotDir);
|
|
986
1178
|
return `${publicUrl}/screenshots/${filename}`;
|
|
987
1179
|
}
|
|
@@ -996,10 +1188,10 @@ function pruneScreenshots(dir, opts = {}) {
|
|
|
996
1188
|
const maxFiles = opts.maxFiles ?? MAX_FILES;
|
|
997
1189
|
const now = opts.now ?? Date.now();
|
|
998
1190
|
try {
|
|
999
|
-
const entries =
|
|
1000
|
-
const full =
|
|
1191
|
+
const entries = import_fs5.default.readdirSync(dir).filter((f) => f.endsWith(".jpg") || f.endsWith(".jpeg") || f.endsWith(".png")).map((f) => {
|
|
1192
|
+
const full = import_path4.default.join(dir, f);
|
|
1001
1193
|
try {
|
|
1002
|
-
return { full, mtimeMs:
|
|
1194
|
+
return { full, mtimeMs: import_fs5.default.statSync(full).mtimeMs };
|
|
1003
1195
|
} catch {
|
|
1004
1196
|
return null;
|
|
1005
1197
|
}
|
|
@@ -1009,7 +1201,7 @@ function pruneScreenshots(dir, opts = {}) {
|
|
|
1009
1201
|
for (const e of entries) {
|
|
1010
1202
|
if (now - e.mtimeMs > maxAgeMs) {
|
|
1011
1203
|
try {
|
|
1012
|
-
|
|
1204
|
+
import_fs5.default.unlinkSync(e.full);
|
|
1013
1205
|
removed++;
|
|
1014
1206
|
} catch {
|
|
1015
1207
|
}
|
|
@@ -1021,23 +1213,23 @@ function pruneScreenshots(dir, opts = {}) {
|
|
|
1021
1213
|
survivors.sort((a, b) => a.mtimeMs - b.mtimeMs);
|
|
1022
1214
|
for (const e of survivors.slice(0, survivors.length - maxFiles)) {
|
|
1023
1215
|
try {
|
|
1024
|
-
|
|
1216
|
+
import_fs5.default.unlinkSync(e.full);
|
|
1025
1217
|
removed++;
|
|
1026
1218
|
} catch {
|
|
1027
1219
|
}
|
|
1028
1220
|
}
|
|
1029
1221
|
}
|
|
1030
|
-
if (removed > 0)
|
|
1222
|
+
if (removed > 0) log4.debug(`pruned ${removed} old screenshot(s) from ${dir}`);
|
|
1031
1223
|
return removed;
|
|
1032
1224
|
} catch (err) {
|
|
1033
|
-
|
|
1225
|
+
log4.warn(`screenshot prune failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1034
1226
|
return 0;
|
|
1035
1227
|
}
|
|
1036
1228
|
}
|
|
1037
1229
|
|
|
1038
1230
|
// src/lib/session/sqlite-store.ts
|
|
1039
|
-
var
|
|
1040
|
-
var
|
|
1231
|
+
var import_path5 = __toESM(require("path"));
|
|
1232
|
+
var import_fs6 = __toESM(require("fs"));
|
|
1041
1233
|
var IS_BUN = typeof globalThis.Bun !== "undefined";
|
|
1042
1234
|
function createBunDb(dbPath) {
|
|
1043
1235
|
const { Database } = require("bun:sqlite");
|
|
@@ -1076,8 +1268,8 @@ function createNodeDb(dbPath) {
|
|
|
1076
1268
|
var SqliteStore = class {
|
|
1077
1269
|
db;
|
|
1078
1270
|
constructor(dataDir) {
|
|
1079
|
-
|
|
1080
|
-
const dbPath =
|
|
1271
|
+
import_fs6.default.mkdirSync(dataDir, { recursive: true });
|
|
1272
|
+
const dbPath = import_path5.default.join(dataDir, "iframer.db");
|
|
1081
1273
|
this.db = IS_BUN ? createBunDb(dbPath) : createNodeDb(dbPath);
|
|
1082
1274
|
this.db.run(`
|
|
1083
1275
|
CREATE TABLE IF NOT EXISTS sessions (
|
|
@@ -1157,13 +1349,13 @@ var import_patchright2 = require("patchright");
|
|
|
1157
1349
|
var import_crypto2 = require("crypto");
|
|
1158
1350
|
|
|
1159
1351
|
// src/lib/browser/chrome-downloader.ts
|
|
1160
|
-
var
|
|
1161
|
-
var
|
|
1352
|
+
var import_fs7 = __toESM(require("fs"));
|
|
1353
|
+
var import_path6 = __toESM(require("path"));
|
|
1162
1354
|
var import_os3 = __toESM(require("os"));
|
|
1163
|
-
var
|
|
1164
|
-
var
|
|
1355
|
+
var import_child_process3 = require("child_process");
|
|
1356
|
+
var log5 = createLogger("chrome");
|
|
1165
1357
|
var CHROME_VERSIONS_URL = "https://googlechromelabs.github.io/chrome-for-testing/last-known-good-versions-with-downloads.json";
|
|
1166
|
-
var DEFAULT_INSTALL_DIR =
|
|
1358
|
+
var DEFAULT_INSTALL_DIR = import_path6.default.join(import_os3.default.homedir(), ".iframer", "chrome");
|
|
1167
1359
|
function getPlatform() {
|
|
1168
1360
|
const arch = process.arch;
|
|
1169
1361
|
const platform = process.platform;
|
|
@@ -1175,24 +1367,24 @@ function getPlatform() {
|
|
|
1175
1367
|
function getChromeExecutablePath(installDir) {
|
|
1176
1368
|
const platform = process.platform;
|
|
1177
1369
|
if (platform === "darwin") {
|
|
1178
|
-
const entries =
|
|
1370
|
+
const entries = import_fs7.default.readdirSync(installDir).filter((e) => e.startsWith("chrome-"));
|
|
1179
1371
|
const dir = entries[0] || "chrome-mac-arm64";
|
|
1180
|
-
return
|
|
1372
|
+
return import_path6.default.join(installDir, dir, "Google Chrome for Testing.app", "Contents", "MacOS", "Google Chrome for Testing");
|
|
1181
1373
|
}
|
|
1182
1374
|
if (platform === "linux") {
|
|
1183
|
-
const entries =
|
|
1375
|
+
const entries = import_fs7.default.readdirSync(installDir).filter((e) => e.startsWith("chrome-"));
|
|
1184
1376
|
const dir = entries[0] || "chrome-linux64";
|
|
1185
|
-
return
|
|
1377
|
+
return import_path6.default.join(installDir, dir, "chrome");
|
|
1186
1378
|
}
|
|
1187
1379
|
if (platform === "win32") {
|
|
1188
|
-
const entries =
|
|
1380
|
+
const entries = import_fs7.default.readdirSync(installDir).filter((e) => e.startsWith("chrome-"));
|
|
1189
1381
|
const dir = entries[0] || "chrome-win64";
|
|
1190
|
-
return
|
|
1382
|
+
return import_path6.default.join(installDir, dir, "chrome.exe");
|
|
1191
1383
|
}
|
|
1192
1384
|
throw new Error(`Unsupported platform: ${platform}`);
|
|
1193
1385
|
}
|
|
1194
1386
|
async function downloadChrome(installDir = DEFAULT_INSTALL_DIR) {
|
|
1195
|
-
|
|
1387
|
+
log5.info("Downloading Chrome for Testing (first time only)...");
|
|
1196
1388
|
const res = await fetch(CHROME_VERSIONS_URL);
|
|
1197
1389
|
if (!res.ok) throw new Error(`Failed to fetch Chrome versions: ${res.status}`);
|
|
1198
1390
|
const data = await res.json();
|
|
@@ -1203,35 +1395,35 @@ async function downloadChrome(installDir = DEFAULT_INSTALL_DIR) {
|
|
|
1203
1395
|
if (!download2) throw new Error(`No Chrome for Testing download for platform: ${platform}`);
|
|
1204
1396
|
const url = download2.url;
|
|
1205
1397
|
const version = channel.version;
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
const zipPath =
|
|
1398
|
+
log5.debug(`Version ${version} for ${platform}`);
|
|
1399
|
+
log5.debug(`URL: ${url}`);
|
|
1400
|
+
import_fs7.default.mkdirSync(installDir, { recursive: true });
|
|
1401
|
+
const zipPath = import_path6.default.join(installDir, "chrome.zip");
|
|
1210
1402
|
const dlRes = await fetch(url);
|
|
1211
1403
|
if (!dlRes.ok) throw new Error(`Download failed: ${dlRes.status}`);
|
|
1212
1404
|
const buf = Buffer.from(await dlRes.arrayBuffer());
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
(0,
|
|
1216
|
-
|
|
1405
|
+
import_fs7.default.writeFileSync(zipPath, buf);
|
|
1406
|
+
log5.info(`Downloaded ${(buf.length / 1024 / 1024).toFixed(1)}MB`);
|
|
1407
|
+
(0, import_child_process3.execSync)(`unzip -o -q "${zipPath}" -d "${installDir}"`, { stdio: "inherit" });
|
|
1408
|
+
import_fs7.default.unlinkSync(zipPath);
|
|
1217
1409
|
const execPath = getChromeExecutablePath(installDir);
|
|
1218
|
-
if (!
|
|
1410
|
+
if (!import_fs7.default.existsSync(execPath)) {
|
|
1219
1411
|
throw new Error(`Chrome executable not found after extraction: ${execPath}`);
|
|
1220
1412
|
}
|
|
1221
1413
|
if (process.platform !== "win32") {
|
|
1222
|
-
|
|
1414
|
+
import_fs7.default.chmodSync(execPath, 493);
|
|
1223
1415
|
}
|
|
1224
|
-
|
|
1225
|
-
|
|
1416
|
+
import_fs7.default.writeFileSync(import_path6.default.join(installDir, "version.json"), JSON.stringify({ version, platform, downloadedAt: (/* @__PURE__ */ new Date()).toISOString() }));
|
|
1417
|
+
log5.info(`Installed at: ${execPath}`);
|
|
1226
1418
|
return execPath;
|
|
1227
1419
|
}
|
|
1228
1420
|
function findChromeForTesting() {
|
|
1229
1421
|
if (process.env.CHROME_EXECUTABLE) {
|
|
1230
|
-
if (
|
|
1422
|
+
if (import_fs7.default.existsSync(process.env.CHROME_EXECUTABLE)) return process.env.CHROME_EXECUTABLE;
|
|
1231
1423
|
}
|
|
1232
1424
|
try {
|
|
1233
1425
|
const execPath = getChromeExecutablePath(DEFAULT_INSTALL_DIR);
|
|
1234
|
-
if (
|
|
1426
|
+
if (import_fs7.default.existsSync(execPath)) return execPath;
|
|
1235
1427
|
} catch {
|
|
1236
1428
|
}
|
|
1237
1429
|
return null;
|
|
@@ -1251,8 +1443,8 @@ function findChrome() {
|
|
|
1251
1443
|
// Playwright/Patchright installed browsers (Docker containers)
|
|
1252
1444
|
...(() => {
|
|
1253
1445
|
try {
|
|
1254
|
-
const dirs =
|
|
1255
|
-
return dirs.map((d) =>
|
|
1446
|
+
const dirs = import_fs7.default.readdirSync("/ms-playwright").filter((d) => d.startsWith("chromium-")).sort().reverse();
|
|
1447
|
+
return dirs.map((d) => import_path6.default.join("/ms-playwright", d, "chrome-linux", "chrome"));
|
|
1256
1448
|
} catch {
|
|
1257
1449
|
return [];
|
|
1258
1450
|
}
|
|
@@ -1262,7 +1454,7 @@ function findChrome() {
|
|
|
1262
1454
|
"C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe"
|
|
1263
1455
|
];
|
|
1264
1456
|
for (const p of systemPaths) {
|
|
1265
|
-
if (
|
|
1457
|
+
if (import_fs7.default.existsSync(p)) return p;
|
|
1266
1458
|
}
|
|
1267
1459
|
return null;
|
|
1268
1460
|
}
|
|
@@ -1272,10 +1464,10 @@ async function ensureChrome() {
|
|
|
1272
1464
|
try {
|
|
1273
1465
|
return await downloadChrome();
|
|
1274
1466
|
} catch (err) {
|
|
1275
|
-
|
|
1467
|
+
log5.error(`Failed to download Chrome for Testing: ${err instanceof Error ? err.message : String(err)}`);
|
|
1276
1468
|
const system = findChrome();
|
|
1277
1469
|
if (system) {
|
|
1278
|
-
|
|
1470
|
+
log5.warn(`Falling back to system Chrome: ${system}`);
|
|
1279
1471
|
return system;
|
|
1280
1472
|
}
|
|
1281
1473
|
throw new Error("No Chrome found. Download failed and no system Chrome available.");
|
|
@@ -1283,7 +1475,7 @@ async function ensureChrome() {
|
|
|
1283
1475
|
}
|
|
1284
1476
|
|
|
1285
1477
|
// src/lib/browser/cloak-browser.ts
|
|
1286
|
-
var
|
|
1478
|
+
var log6 = createLogger("cloak");
|
|
1287
1479
|
var _available = null;
|
|
1288
1480
|
function cloakEnabled() {
|
|
1289
1481
|
return process.env.IFRAMER_USE_CLOAKBROWSER === "1" || process.env.IFRAMER_USE_CLOAKBROWSER === "true";
|
|
@@ -1302,14 +1494,14 @@ async function ensureBinary() {
|
|
|
1302
1494
|
try {
|
|
1303
1495
|
const info = cloak.binaryInfo();
|
|
1304
1496
|
if (!info.installed) {
|
|
1305
|
-
|
|
1497
|
+
log6.info("Downloading CloakBrowser binary...");
|
|
1306
1498
|
await cloak.ensureBinary();
|
|
1307
|
-
|
|
1499
|
+
log6.info("CloakBrowser ready");
|
|
1308
1500
|
}
|
|
1309
1501
|
_available = true;
|
|
1310
1502
|
return true;
|
|
1311
1503
|
} catch (err) {
|
|
1312
|
-
|
|
1504
|
+
log6.warn(`CloakBrowser setup failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1313
1505
|
_available = false;
|
|
1314
1506
|
return false;
|
|
1315
1507
|
}
|
|
@@ -1324,157 +1516,20 @@ async function launchCloakBrowser(options) {
|
|
|
1324
1516
|
headless: options.headless,
|
|
1325
1517
|
args: options.args
|
|
1326
1518
|
});
|
|
1327
|
-
|
|
1519
|
+
log6.info(`CloakBrowser launched (headless=${options.headless})`);
|
|
1328
1520
|
return browser;
|
|
1329
1521
|
} catch (err) {
|
|
1330
|
-
|
|
1522
|
+
log6.warn(`CloakBrowser launch failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1331
1523
|
return null;
|
|
1332
1524
|
}
|
|
1333
1525
|
}
|
|
1334
1526
|
|
|
1335
|
-
// src/lib/browser/registry.ts
|
|
1336
|
-
var import_fs7 = __toESM(require("fs"));
|
|
1337
|
-
var import_path6 = __toESM(require("path"));
|
|
1338
|
-
var import_child_process3 = require("child_process");
|
|
1339
|
-
init_paths();
|
|
1340
|
-
var log6 = createLogger("registry");
|
|
1341
|
-
function browsersDir() {
|
|
1342
|
-
const dir = import_path6.default.join(getDataDir(), "browsers");
|
|
1343
|
-
import_fs7.default.mkdirSync(dir, { recursive: true });
|
|
1344
|
-
return dir;
|
|
1345
|
-
}
|
|
1346
|
-
function serverInfoPath() {
|
|
1347
|
-
return import_path6.default.join(getDataDir(), "server.json");
|
|
1348
|
-
}
|
|
1349
|
-
function isPidAlive(pid) {
|
|
1350
|
-
if (!Number.isInteger(pid) || pid <= 1) return false;
|
|
1351
|
-
try {
|
|
1352
|
-
process.kill(pid, 0);
|
|
1353
|
-
return true;
|
|
1354
|
-
} catch {
|
|
1355
|
-
return false;
|
|
1356
|
-
}
|
|
1357
|
-
}
|
|
1358
|
-
function pidMatchesMarker(pid, marker) {
|
|
1359
|
-
if (!isPidAlive(pid)) return false;
|
|
1360
|
-
try {
|
|
1361
|
-
const cmd = (0, import_child_process3.execSync)(`ps -o command= -p ${pid}`, { encoding: "utf8" });
|
|
1362
|
-
return cmd.includes(marker);
|
|
1363
|
-
} catch {
|
|
1364
|
-
return false;
|
|
1365
|
-
}
|
|
1366
|
-
}
|
|
1367
|
-
function findChromePidByMarker(marker) {
|
|
1368
|
-
try {
|
|
1369
|
-
const out = (0, import_child_process3.execSync)(`pgrep -f -- "${marker}"`, { encoding: "utf8" }).trim();
|
|
1370
|
-
const pids = out.split("\n").map((s) => parseInt(s, 10)).filter((n) => Number.isInteger(n) && n !== process.pid);
|
|
1371
|
-
if (pids.length === 0) return null;
|
|
1372
|
-
return Math.min(...pids);
|
|
1373
|
-
} catch {
|
|
1374
|
-
return null;
|
|
1375
|
-
}
|
|
1376
|
-
}
|
|
1377
|
-
function registerBrowser(rec) {
|
|
1378
|
-
try {
|
|
1379
|
-
import_fs7.default.writeFileSync(import_path6.default.join(browsersDir(), `${rec.chromePid}.json`), JSON.stringify(rec, null, 2));
|
|
1380
|
-
} catch (err) {
|
|
1381
|
-
log6.warn(`failed to write browser record for pid ${rec.chromePid}: ${err}`);
|
|
1382
|
-
}
|
|
1383
|
-
}
|
|
1384
|
-
function unregisterBrowser(chromePid) {
|
|
1385
|
-
try {
|
|
1386
|
-
import_fs7.default.unlinkSync(import_path6.default.join(browsersDir(), `${chromePid}.json`));
|
|
1387
|
-
} catch {
|
|
1388
|
-
}
|
|
1389
|
-
}
|
|
1390
|
-
var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
1391
|
-
async function forceKillBrowser(rec) {
|
|
1392
|
-
if (!isPidAlive(rec.chromePid)) return true;
|
|
1393
|
-
if (!pidMatchesMarker(rec.chromePid, rec.marker)) {
|
|
1394
|
-
return true;
|
|
1395
|
-
}
|
|
1396
|
-
try {
|
|
1397
|
-
process.kill(rec.chromePid, "SIGKILL");
|
|
1398
|
-
} catch {
|
|
1399
|
-
}
|
|
1400
|
-
const deadline = Date.now() + 2e3;
|
|
1401
|
-
while (Date.now() < deadline) {
|
|
1402
|
-
if (!isPidAlive(rec.chromePid)) return true;
|
|
1403
|
-
await sleep(100);
|
|
1404
|
-
}
|
|
1405
|
-
return !isPidAlive(rec.chromePid);
|
|
1406
|
-
}
|
|
1407
|
-
async function reapOrphanBrowsers() {
|
|
1408
|
-
let reaped = 0;
|
|
1409
|
-
let skipped = 0;
|
|
1410
|
-
let files = [];
|
|
1411
|
-
try {
|
|
1412
|
-
files = import_fs7.default.readdirSync(browsersDir()).filter((f) => f.endsWith(".json"));
|
|
1413
|
-
} catch {
|
|
1414
|
-
return { reaped, skipped };
|
|
1415
|
-
}
|
|
1416
|
-
for (const file of files) {
|
|
1417
|
-
const full = import_path6.default.join(browsersDir(), file);
|
|
1418
|
-
let rec;
|
|
1419
|
-
try {
|
|
1420
|
-
rec = JSON.parse(import_fs7.default.readFileSync(full, "utf8"));
|
|
1421
|
-
} catch {
|
|
1422
|
-
try {
|
|
1423
|
-
import_fs7.default.unlinkSync(full);
|
|
1424
|
-
} catch {
|
|
1425
|
-
}
|
|
1426
|
-
continue;
|
|
1427
|
-
}
|
|
1428
|
-
if (!isPidAlive(rec.chromePid) || !pidMatchesMarker(rec.chromePid, rec.marker)) {
|
|
1429
|
-
try {
|
|
1430
|
-
import_fs7.default.unlinkSync(full);
|
|
1431
|
-
} catch {
|
|
1432
|
-
}
|
|
1433
|
-
continue;
|
|
1434
|
-
}
|
|
1435
|
-
if (isPidAlive(rec.ownerPid)) {
|
|
1436
|
-
skipped++;
|
|
1437
|
-
continue;
|
|
1438
|
-
}
|
|
1439
|
-
log6.info(`reaping orphan Chrome pid=${rec.chromePid} (${rec.key}), owner ${rec.ownerPid} is dead`);
|
|
1440
|
-
if (await forceKillBrowser(rec)) {
|
|
1441
|
-
try {
|
|
1442
|
-
import_fs7.default.unlinkSync(full);
|
|
1443
|
-
} catch {
|
|
1444
|
-
}
|
|
1445
|
-
reaped++;
|
|
1446
|
-
} else {
|
|
1447
|
-
log6.warn(`failed to kill orphan Chrome pid=${rec.chromePid} \u2014 leaving record for next sweep`);
|
|
1448
|
-
}
|
|
1449
|
-
}
|
|
1450
|
-
return { reaped, skipped };
|
|
1451
|
-
}
|
|
1452
|
-
function writeServerInfo(info) {
|
|
1453
|
-
import_fs7.default.writeFileSync(serverInfoPath(), JSON.stringify(info, null, 2));
|
|
1454
|
-
}
|
|
1455
|
-
function readServerInfo() {
|
|
1456
|
-
try {
|
|
1457
|
-
const info = JSON.parse(import_fs7.default.readFileSync(serverInfoPath(), "utf8"));
|
|
1458
|
-
if (!Number.isInteger(info.pid) || !Number.isInteger(info.port)) return null;
|
|
1459
|
-
return info;
|
|
1460
|
-
} catch {
|
|
1461
|
-
return null;
|
|
1462
|
-
}
|
|
1463
|
-
}
|
|
1464
|
-
function clearServerInfo(pid) {
|
|
1465
|
-
const info = readServerInfo();
|
|
1466
|
-
if (info && info.pid === pid) {
|
|
1467
|
-
try {
|
|
1468
|
-
import_fs7.default.unlinkSync(serverInfoPath());
|
|
1469
|
-
} catch {
|
|
1470
|
-
}
|
|
1471
|
-
}
|
|
1472
|
-
}
|
|
1473
|
-
|
|
1474
1527
|
// src/lib/browser/daemon.ts
|
|
1475
1528
|
var log7 = createLogger("daemon");
|
|
1476
1529
|
var DEFAULT_IDLE_TIMEOUT = 5 * 60 * 1e3;
|
|
1477
1530
|
var CLOSE_GRACE_MS = 5e3;
|
|
1531
|
+
var BUSY_MAX_MS = 30 * 60 * 1e3;
|
|
1532
|
+
var BLANK_IDLE_MS = 2 * 60 * 1e3;
|
|
1478
1533
|
var DEFAULT_INSTANCE = "default";
|
|
1479
1534
|
function keyOf(mode, instanceId) {
|
|
1480
1535
|
return `${mode}::${instanceId}`;
|
|
@@ -1566,7 +1621,8 @@ var BrowserDaemon = class {
|
|
|
1566
1621
|
createdAt: /* @__PURE__ */ new Date(),
|
|
1567
1622
|
chromePid,
|
|
1568
1623
|
marker,
|
|
1569
|
-
active: 0
|
|
1624
|
+
active: 0,
|
|
1625
|
+
busySince: null
|
|
1570
1626
|
};
|
|
1571
1627
|
this.instances.set(key, instance);
|
|
1572
1628
|
this.resetIdleTimer(key);
|
|
@@ -1578,14 +1634,19 @@ var BrowserDaemon = class {
|
|
|
1578
1634
|
* Always pair with release() in a finally block. */
|
|
1579
1635
|
acquire(mode, instanceId = DEFAULT_INSTANCE) {
|
|
1580
1636
|
const instance = this.instances.get(keyOf(mode, instanceId));
|
|
1581
|
-
if (instance)
|
|
1637
|
+
if (!instance) return;
|
|
1638
|
+
if (instance.active === 0) instance.busySince = Date.now();
|
|
1639
|
+
instance.active++;
|
|
1582
1640
|
}
|
|
1583
1641
|
release(mode, instanceId = DEFAULT_INSTANCE) {
|
|
1584
1642
|
const key = keyOf(mode, instanceId);
|
|
1585
1643
|
const instance = this.instances.get(key);
|
|
1586
1644
|
if (!instance) return;
|
|
1587
1645
|
instance.active = Math.max(0, instance.active - 1);
|
|
1588
|
-
if (instance.active === 0)
|
|
1646
|
+
if (instance.active === 0) {
|
|
1647
|
+
instance.busySince = null;
|
|
1648
|
+
this.resetIdleTimer(key);
|
|
1649
|
+
}
|
|
1589
1650
|
}
|
|
1590
1651
|
isRunning(mode, instanceId = DEFAULT_INSTANCE) {
|
|
1591
1652
|
const instance = this.instances.get(keyOf(mode, instanceId));
|
|
@@ -1596,6 +1657,35 @@ var BrowserDaemon = class {
|
|
|
1596
1657
|
return false;
|
|
1597
1658
|
}
|
|
1598
1659
|
}
|
|
1660
|
+
/** Close instances that never left about:blank and have been idle a while.
|
|
1661
|
+
* Those are accidents — a pipeline that failed before its first navigate, or
|
|
1662
|
+
* a mode picked for a call that never needed a browser — and in headful mode
|
|
1663
|
+
* each one is an empty window sitting on the user's desktop. Called on the
|
|
1664
|
+
* server's reap tick, so it also catches instances whose idle timer was lost
|
|
1665
|
+
* (e.g. the daemon was restarted under them). */
|
|
1666
|
+
sweepBlankInstances() {
|
|
1667
|
+
const stopped = [];
|
|
1668
|
+
const now = Date.now();
|
|
1669
|
+
for (const [key, inst] of this.instances.entries()) {
|
|
1670
|
+
if (inst.active > 0) continue;
|
|
1671
|
+
if (now - inst.createdAt.getTime() < BLANK_IDLE_MS) continue;
|
|
1672
|
+
let blank = false;
|
|
1673
|
+
try {
|
|
1674
|
+
const pages = inst.context.pages();
|
|
1675
|
+
blank = pages.length === 0 || pages.every((p) => {
|
|
1676
|
+
const u = p.url();
|
|
1677
|
+
return u === "about:blank" || u === "" || u === "chrome://newtab/";
|
|
1678
|
+
});
|
|
1679
|
+
} catch {
|
|
1680
|
+
continue;
|
|
1681
|
+
}
|
|
1682
|
+
if (!blank) continue;
|
|
1683
|
+
log7.info(`blank-orphan sweep: ${key} never left about:blank, stopping`);
|
|
1684
|
+
stopped.push(key);
|
|
1685
|
+
this.stopKey(key).catch((err) => log7.warn(`blank sweep stop failed for ${key}: ${err}`));
|
|
1686
|
+
}
|
|
1687
|
+
return stopped;
|
|
1688
|
+
}
|
|
1599
1689
|
/** Distinct modes that currently have at least one live instance. */
|
|
1600
1690
|
runningModes() {
|
|
1601
1691
|
return [...new Set(this.liveInstances().map((i) => i.mode))];
|
|
@@ -1714,8 +1804,14 @@ var BrowserDaemon = class {
|
|
|
1714
1804
|
setTimeout(() => {
|
|
1715
1805
|
const instance = this.instances.get(key);
|
|
1716
1806
|
if (instance && instance.active > 0) {
|
|
1717
|
-
|
|
1718
|
-
|
|
1807
|
+
const busyMs = instance.busySince ? Date.now() - instance.busySince : 0;
|
|
1808
|
+
if (busyMs < BUSY_MAX_MS) {
|
|
1809
|
+
this.resetIdleTimer(key);
|
|
1810
|
+
return;
|
|
1811
|
+
}
|
|
1812
|
+
log7.warn(`${key} reported busy for ${Math.round(busyMs / 6e4)}m \u2014 treating active=${instance.active} as leaked, stopping`);
|
|
1813
|
+
instance.active = 0;
|
|
1814
|
+
instance.busySince = null;
|
|
1719
1815
|
}
|
|
1720
1816
|
log7.info(`Idle timeout for ${key}, stopping...`);
|
|
1721
1817
|
this.stopKey(key).catch((err) => log7.warn(`idle stop failed for ${key}: ${err}`));
|
|
@@ -2074,6 +2170,24 @@ async function humanType(page, selector, text, opts = {}) {
|
|
|
2074
2170
|
await sleep3(randRange(TIMING.POST_CLICK));
|
|
2075
2171
|
}
|
|
2076
2172
|
await assertFocused(page, selector, !opts.skipClick);
|
|
2173
|
+
if (opts.clear) {
|
|
2174
|
+
const hadText = await page.evaluate(() => {
|
|
2175
|
+
const el = document.activeElement;
|
|
2176
|
+
if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {
|
|
2177
|
+
el.select();
|
|
2178
|
+
return el.value.length > 0;
|
|
2179
|
+
}
|
|
2180
|
+
if (el?.isContentEditable) {
|
|
2181
|
+
window.getSelection()?.selectAllChildren(el);
|
|
2182
|
+
return (el.textContent || "").length > 0;
|
|
2183
|
+
}
|
|
2184
|
+
return false;
|
|
2185
|
+
});
|
|
2186
|
+
if (hadText) {
|
|
2187
|
+
await page.keyboard.press("Backspace");
|
|
2188
|
+
await sleep3(randRange(TIMING.CHAR_DELAY));
|
|
2189
|
+
}
|
|
2190
|
+
}
|
|
2077
2191
|
const factor = SPEED_FACTOR[opts.speed || "normal"] ?? 1;
|
|
2078
2192
|
for (const char of text) {
|
|
2079
2193
|
await page.keyboard.type(char);
|
|
@@ -2229,7 +2343,36 @@ function sleep3(ms) {
|
|
|
2229
2343
|
// src/lib/actions/handlers/navigation.ts
|
|
2230
2344
|
init_persistence();
|
|
2231
2345
|
|
|
2346
|
+
// src/lib/budget.ts
|
|
2347
|
+
var BudgetExceededError = class extends Error {
|
|
2348
|
+
constructor(label, ms) {
|
|
2349
|
+
super(`${label} did not answer within ${ms}ms`);
|
|
2350
|
+
this.name = "BudgetExceededError";
|
|
2351
|
+
}
|
|
2352
|
+
};
|
|
2353
|
+
function withBudget(p, ms, label = "page call") {
|
|
2354
|
+
p.catch(() => void 0);
|
|
2355
|
+
let timer;
|
|
2356
|
+
return Promise.race([
|
|
2357
|
+
p,
|
|
2358
|
+
new Promise((_, reject) => {
|
|
2359
|
+
timer = setTimeout(() => reject(new BudgetExceededError(label, ms)), ms);
|
|
2360
|
+
timer.unref?.();
|
|
2361
|
+
})
|
|
2362
|
+
]).finally(() => {
|
|
2363
|
+
if (timer) clearTimeout(timer);
|
|
2364
|
+
});
|
|
2365
|
+
}
|
|
2366
|
+
|
|
2232
2367
|
// src/lib/actions/resolve-selector.ts
|
|
2368
|
+
var 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/;
|
|
2369
|
+
function assertCssSelector(selector) {
|
|
2370
|
+
if (PLAYWRIGHT_ONLY.test(selector)) {
|
|
2371
|
+
throw new Error(
|
|
2372
|
+
`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.`
|
|
2373
|
+
);
|
|
2374
|
+
}
|
|
2375
|
+
}
|
|
2233
2376
|
function resolveSelector(selector, ctx) {
|
|
2234
2377
|
if (selector.startsWith("@a:")) {
|
|
2235
2378
|
const name = selector.slice(3);
|
|
@@ -2250,19 +2393,149 @@ function resolveSelector(selector, ctx) {
|
|
|
2250
2393
|
}
|
|
2251
2394
|
return ref.selector;
|
|
2252
2395
|
}
|
|
2396
|
+
assertCssSelector(selector);
|
|
2253
2397
|
return selector;
|
|
2254
2398
|
}
|
|
2255
2399
|
function anchorNameOf(selector) {
|
|
2256
2400
|
return typeof selector === "string" && selector.startsWith("@a:") ? selector.slice(3) : null;
|
|
2257
2401
|
}
|
|
2258
2402
|
|
|
2403
|
+
// src/lib/reachability.ts
|
|
2404
|
+
var HONEYPOT_REASONS = /* @__PURE__ */ new Set(["zero-size", "hidden", "transparent", "offscreen", "clipped"]);
|
|
2405
|
+
var HONEYPOT_NAME_RE = /^(website|url|homepage|fax|honeypot|honey|hp|hp_[\w-]*|bot|bot_[\w-]*|trap|_gotcha|leave_?blank|do_?not_?fill)$/i;
|
|
2406
|
+
function isHoneypotReason(reason) {
|
|
2407
|
+
return !!reason && HONEYPOT_REASONS.has(reason);
|
|
2408
|
+
}
|
|
2409
|
+
function looksLikeBaitField(attrs) {
|
|
2410
|
+
const name = attrs.name || "";
|
|
2411
|
+
const id = attrs.id || "";
|
|
2412
|
+
if (HONEYPOT_NAME_RE.test(name) || HONEYPOT_NAME_RE.test(id)) return true;
|
|
2413
|
+
const textual = !attrs.type || /^(text|email|url|tel|search|number)$/i.test(attrs.type);
|
|
2414
|
+
if (textual && attrs.tabindex === "-1") return true;
|
|
2415
|
+
return false;
|
|
2416
|
+
}
|
|
2417
|
+
function honeypotRefusalMessage(selector, reason, name) {
|
|
2418
|
+
const what = name ? `"${name}" (${selector})` : selector;
|
|
2419
|
+
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.`;
|
|
2420
|
+
}
|
|
2421
|
+
async function probeField(page, selector) {
|
|
2422
|
+
const r = await page.evaluate((sel) => {
|
|
2423
|
+
const el = document.querySelector(sel);
|
|
2424
|
+
if (!el) return { found: false };
|
|
2425
|
+
const attrs = {
|
|
2426
|
+
name: el.getAttribute("name"),
|
|
2427
|
+
id: el.id || null,
|
|
2428
|
+
tabindex: el.getAttribute("tabindex"),
|
|
2429
|
+
type: el.getAttribute("type")
|
|
2430
|
+
};
|
|
2431
|
+
const tag = el.tagName.toLowerCase();
|
|
2432
|
+
{
|
|
2433
|
+
const r0 = el.getBoundingClientRect();
|
|
2434
|
+
const cx0 = r0.left + r0.width / 2, cy0 = r0.top + r0.height / 2;
|
|
2435
|
+
const dx = cx0 < 0 || cx0 >= window.innerWidth ? cx0 - window.innerWidth / 2 : 0;
|
|
2436
|
+
const dy = cy0 < 0 || cy0 >= window.innerHeight ? cy0 - window.innerHeight / 2 : 0;
|
|
2437
|
+
if (dx || dy) try {
|
|
2438
|
+
window.scrollBy(dx, dy);
|
|
2439
|
+
} catch {
|
|
2440
|
+
}
|
|
2441
|
+
}
|
|
2442
|
+
const rect = el.getBoundingClientRect();
|
|
2443
|
+
let reason = null;
|
|
2444
|
+
if (rect.width === 0 || rect.height === 0) reason = "zero-size";
|
|
2445
|
+
const style = getComputedStyle(el);
|
|
2446
|
+
if (!reason && (style.display === "none" || style.visibility === "hidden")) reason = "hidden";
|
|
2447
|
+
if (!reason) {
|
|
2448
|
+
let opacity = 1;
|
|
2449
|
+
for (let n = el; n && n !== document.documentElement; n = n.parentElement) {
|
|
2450
|
+
const o = parseFloat(getComputedStyle(n).opacity);
|
|
2451
|
+
const animating = o < 1 && typeof n.getAnimations === "function" && n.getAnimations().length > 0;
|
|
2452
|
+
if (!isNaN(o) && !animating) opacity *= o;
|
|
2453
|
+
}
|
|
2454
|
+
if (opacity < 0.05) reason = "transparent";
|
|
2455
|
+
}
|
|
2456
|
+
if (!reason) {
|
|
2457
|
+
const docW = Math.max(document.documentElement.scrollWidth, window.innerWidth);
|
|
2458
|
+
const docH = Math.max(document.documentElement.scrollHeight, window.innerHeight);
|
|
2459
|
+
const left = rect.left + window.scrollX, right = rect.right + window.scrollX;
|
|
2460
|
+
const top = rect.top + window.scrollY, bottom = rect.bottom + window.scrollY;
|
|
2461
|
+
if (right <= 0 || left >= docW || bottom <= 0 || top >= docH) reason = "offscreen";
|
|
2462
|
+
}
|
|
2463
|
+
if (!reason) {
|
|
2464
|
+
const cx = rect.left + rect.width / 2, cy = rect.top + rect.height / 2;
|
|
2465
|
+
const inViewport = cx >= 0 && cy >= 0 && cx < window.innerWidth && cy < window.innerHeight;
|
|
2466
|
+
let clipped = false;
|
|
2467
|
+
for (let a = el.parentElement; a && a !== document.body; a = a.parentElement) {
|
|
2468
|
+
const s = getComputedStyle(a);
|
|
2469
|
+
const clipsX = /hidden|clip/.test(s.overflowX), clipsY = /hidden|clip/.test(s.overflowY);
|
|
2470
|
+
if (!clipsX && !clipsY) continue;
|
|
2471
|
+
const ar = a.getBoundingClientRect();
|
|
2472
|
+
const outside = clipsX && (cx < ar.left || cx > ar.right) || clipsY && (cy < ar.top || cy > ar.bottom);
|
|
2473
|
+
const tiny = clipsY && ar.height <= 2 || clipsX && ar.width <= 2;
|
|
2474
|
+
if (outside || tiny) {
|
|
2475
|
+
clipped = true;
|
|
2476
|
+
break;
|
|
2477
|
+
}
|
|
2478
|
+
}
|
|
2479
|
+
if (inViewport) {
|
|
2480
|
+
const hit = document.elementFromPoint(cx, cy);
|
|
2481
|
+
if (!(hit && (hit === el || el.contains(hit)))) reason = clipped ? "clipped" : "covered";
|
|
2482
|
+
} else if (clipped) {
|
|
2483
|
+
reason = "clipped";
|
|
2484
|
+
}
|
|
2485
|
+
}
|
|
2486
|
+
return { found: true, reason, attrs, tag };
|
|
2487
|
+
}, selector);
|
|
2488
|
+
if (!r.found) return { found: false, hiddenReason: null, suspiciousName: false };
|
|
2489
|
+
return {
|
|
2490
|
+
found: true,
|
|
2491
|
+
hiddenReason: r.reason ?? null,
|
|
2492
|
+
suspiciousName: looksLikeBaitField(r.attrs),
|
|
2493
|
+
tag: r.tag,
|
|
2494
|
+
name: r.attrs.name ?? void 0
|
|
2495
|
+
};
|
|
2496
|
+
}
|
|
2497
|
+
|
|
2259
2498
|
// src/lib/actions/handlers/navigation.ts
|
|
2260
2499
|
var log9 = createLogger("actions");
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
2500
|
+
var NAV_COMMIT_SUSPECT_MS = 12e3;
|
|
2501
|
+
async function navigate(page, step, ctx, monitor) {
|
|
2502
|
+
const waitUntil = step.waitUntil || "domcontentloaded";
|
|
2503
|
+
const urlBefore = page.url();
|
|
2504
|
+
const onDialog = (d) => {
|
|
2505
|
+
(d.type() === "beforeunload" ? d.accept() : d.dismiss()).catch(() => void 0);
|
|
2506
|
+
};
|
|
2507
|
+
page.on("dialog", onDialog);
|
|
2508
|
+
try {
|
|
2509
|
+
const nav = page.goto(step.url, { waitUntil, timeout: TIMEOUTS.NAVIGATION });
|
|
2510
|
+
nav.catch(() => void 0);
|
|
2511
|
+
let suspectTimer;
|
|
2512
|
+
const outcome = await Promise.race([
|
|
2513
|
+
nav.then(() => "done"),
|
|
2514
|
+
new Promise((r) => {
|
|
2515
|
+
suspectTimer = setTimeout(() => r("slow"), NAV_COMMIT_SUSPECT_MS);
|
|
2516
|
+
})
|
|
2517
|
+
]).finally(() => clearTimeout(suspectTimer));
|
|
2518
|
+
if (outcome === "slow") {
|
|
2519
|
+
if (page.url() !== urlBefore) {
|
|
2520
|
+
await nav;
|
|
2521
|
+
} else {
|
|
2522
|
+
log9.warn(`navigate: no commit after ${NAV_COMMIT_SUSPECT_MS}ms, falling back to location.assign`);
|
|
2523
|
+
monitor?.reportActivity();
|
|
2524
|
+
await withBudget(page.evaluate((u) => {
|
|
2525
|
+
location.assign(u);
|
|
2526
|
+
}, step.url), 3e3, "location.assign").catch(() => void 0);
|
|
2527
|
+
try {
|
|
2528
|
+
await page.waitForURL((u) => u.toString() !== urlBefore, { timeout: 15e3, waitUntil });
|
|
2529
|
+
} catch {
|
|
2530
|
+
throw new Error(
|
|
2531
|
+
`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.`
|
|
2532
|
+
);
|
|
2533
|
+
}
|
|
2534
|
+
}
|
|
2535
|
+
}
|
|
2536
|
+
} finally {
|
|
2537
|
+
page.off("dialog", onDialog);
|
|
2538
|
+
}
|
|
2266
2539
|
const stealthScript = contextStealthScripts.get(page.context()) ?? STEALTH_SCRIPT;
|
|
2267
2540
|
try {
|
|
2268
2541
|
await page.evaluate(stealthScript);
|
|
@@ -2278,14 +2551,37 @@ async function navigate(page, step, ctx) {
|
|
|
2278
2551
|
}
|
|
2279
2552
|
}
|
|
2280
2553
|
async function click(page, step, ctx) {
|
|
2281
|
-
await page.click(resolveSelector(step.selector, ctx));
|
|
2554
|
+
await page.click(resolveSelector(step.selector, ctx), { timeout: TIMEOUTS.CLICK });
|
|
2555
|
+
}
|
|
2556
|
+
async function isCombobox(page, selector) {
|
|
2557
|
+
return withBudget(
|
|
2558
|
+
page.evaluate((sel) => {
|
|
2559
|
+
const el = document.querySelector(sel);
|
|
2560
|
+
if (!el) return false;
|
|
2561
|
+
const role = el.getAttribute("role");
|
|
2562
|
+
return role === "combobox" || el.hasAttribute("aria-autocomplete") || el.getAttribute("aria-haspopup") === "listbox" || el.hasAttribute("list") || !!el.closest('[role="combobox"]');
|
|
2563
|
+
}, selector),
|
|
2564
|
+
3e3
|
|
2565
|
+
).catch(() => false);
|
|
2282
2566
|
}
|
|
2283
2567
|
async function fill(page, step, ctx) {
|
|
2284
2568
|
const selector = resolveSelector(step.selector, ctx);
|
|
2285
2569
|
const value = step.value;
|
|
2570
|
+
if (!step.force) {
|
|
2571
|
+
let probe = null;
|
|
2572
|
+
try {
|
|
2573
|
+
probe = await probeField(page, selector);
|
|
2574
|
+
} catch {
|
|
2575
|
+
probe = null;
|
|
2576
|
+
}
|
|
2577
|
+
if (probe?.found && isHoneypotReason(probe.hiddenReason)) {
|
|
2578
|
+
throw new Error(honeypotRefusalMessage(step.selector, probe.hiddenReason, probe.name));
|
|
2579
|
+
}
|
|
2580
|
+
}
|
|
2581
|
+
const combobox = await isCombobox(page, selector);
|
|
2286
2582
|
await page.fill(selector, value);
|
|
2287
2583
|
const stuck = await page.evaluate(
|
|
2288
|
-
({ sel, val }) => {
|
|
2584
|
+
({ sel, val, keepFocus }) => {
|
|
2289
2585
|
const el = document.querySelector(sel);
|
|
2290
2586
|
if (!el) return false;
|
|
2291
2587
|
const proto = el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
|
|
@@ -2301,13 +2597,15 @@ async function fill(page, step, ctx) {
|
|
|
2301
2597
|
}
|
|
2302
2598
|
el.dispatchEvent(new Event("input", { bubbles: true }));
|
|
2303
2599
|
el.dispatchEvent(new Event("change", { bubbles: true }));
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2600
|
+
if (!keepFocus) {
|
|
2601
|
+
try {
|
|
2602
|
+
el.blur();
|
|
2603
|
+
} catch {
|
|
2604
|
+
}
|
|
2307
2605
|
}
|
|
2308
2606
|
return el.value === val;
|
|
2309
2607
|
},
|
|
2310
|
-
{ sel: selector, val: value }
|
|
2608
|
+
{ sel: selector, val: value, keepFocus: combobox }
|
|
2311
2609
|
);
|
|
2312
2610
|
if (!stuck) {
|
|
2313
2611
|
const loc = page.locator(selector);
|
|
@@ -2315,10 +2613,12 @@ async function fill(page, step, ctx) {
|
|
|
2315
2613
|
await loc.click();
|
|
2316
2614
|
await loc.fill("");
|
|
2317
2615
|
await loc.pressSequentially(value, { delay: 15 });
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2616
|
+
if (!combobox) {
|
|
2617
|
+
await page.evaluate((sel) => {
|
|
2618
|
+
const el = document.querySelector(sel);
|
|
2619
|
+
el?.blur?.();
|
|
2620
|
+
}, selector);
|
|
2621
|
+
}
|
|
2322
2622
|
} catch {
|
|
2323
2623
|
}
|
|
2324
2624
|
}
|
|
@@ -2342,7 +2642,7 @@ async function rightClick(page, step, ctx) {
|
|
|
2342
2642
|
}
|
|
2343
2643
|
}
|
|
2344
2644
|
async function humanTypeStep(page, step, ctx) {
|
|
2345
|
-
await humanType(page, resolveSelector(step.selector, ctx), step.value, { skipClick: step.skipClick, speed: step.speed });
|
|
2645
|
+
await humanType(page, resolveSelector(step.selector, ctx), step.value, { skipClick: step.skipClick, speed: step.speed, clear: step.clear });
|
|
2346
2646
|
}
|
|
2347
2647
|
async function selectOption(page, step, ctx) {
|
|
2348
2648
|
const selector = resolveSelector(step.selector, ctx);
|
|
@@ -2385,7 +2685,29 @@ async function scroll(page, step, ctx) {
|
|
|
2385
2685
|
{ dy: step.deltaY ?? 0, sel: selector }
|
|
2386
2686
|
);
|
|
2387
2687
|
}
|
|
2388
|
-
async function
|
|
2688
|
+
async function selectAllInFocused(page) {
|
|
2689
|
+
await page.evaluate(() => {
|
|
2690
|
+
const el = document.activeElement;
|
|
2691
|
+
if (el && (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement)) {
|
|
2692
|
+
el.select();
|
|
2693
|
+
} else if (el && el.isContentEditable) {
|
|
2694
|
+
const sel = window.getSelection();
|
|
2695
|
+
sel?.selectAllChildren(el);
|
|
2696
|
+
} else {
|
|
2697
|
+
window.getSelection()?.selectAllChildren(document.body);
|
|
2698
|
+
}
|
|
2699
|
+
});
|
|
2700
|
+
}
|
|
2701
|
+
async function keyboard(page, step, ctx) {
|
|
2702
|
+
if ((step.meta || step.ctrl) && step.key.toLowerCase() === "a" && !step.shift && !step.alt) {
|
|
2703
|
+
await selectAllInFocused(page);
|
|
2704
|
+
return;
|
|
2705
|
+
}
|
|
2706
|
+
if ((step.meta || step.ctrl) && ctx.driver === "extension") {
|
|
2707
|
+
throw new Error(
|
|
2708
|
+
`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.`
|
|
2709
|
+
);
|
|
2710
|
+
}
|
|
2389
2711
|
const mods = [
|
|
2390
2712
|
step.meta ? "Meta" : null,
|
|
2391
2713
|
step.ctrl ? "Control" : null,
|
|
@@ -2570,6 +2892,56 @@ async function takeSnapshot(page, ctx, options) {
|
|
|
2570
2892
|
if (rect.bottom < 0 || rect.top > window.innerHeight + 200) return false;
|
|
2571
2893
|
return true;
|
|
2572
2894
|
}
|
|
2895
|
+
function isFillable(el) {
|
|
2896
|
+
if (el.tagName === "TEXTAREA" || el.tagName === "SELECT") return true;
|
|
2897
|
+
if (el.hasAttribute("contenteditable") && el.getAttribute("contenteditable") !== "false") return true;
|
|
2898
|
+
if (el.tagName !== "INPUT") return false;
|
|
2899
|
+
const t = (el.type || "text").toLowerCase();
|
|
2900
|
+
return !["button", "submit", "reset", "image", "checkbox", "radio", "file", "hidden", "range", "color"].includes(t);
|
|
2901
|
+
}
|
|
2902
|
+
function unreachableReason(el) {
|
|
2903
|
+
const rect = el.getBoundingClientRect();
|
|
2904
|
+
const style = getComputedStyle(el);
|
|
2905
|
+
let opacity = 1;
|
|
2906
|
+
for (let n = el; n && n !== document.documentElement; n = n.parentElement) {
|
|
2907
|
+
const o = parseFloat(n === el ? style.opacity : getComputedStyle(n).opacity);
|
|
2908
|
+
const animating = o < 1 && typeof n.getAnimations === "function" && n.getAnimations().length > 0;
|
|
2909
|
+
if (!isNaN(o) && !animating) opacity *= o;
|
|
2910
|
+
}
|
|
2911
|
+
if (opacity < 0.05) return "transparent";
|
|
2912
|
+
const docW = Math.max(document.documentElement.scrollWidth, window.innerWidth);
|
|
2913
|
+
const docH = Math.max(document.documentElement.scrollHeight, window.innerHeight);
|
|
2914
|
+
const left = rect.left + window.scrollX, right = rect.right + window.scrollX;
|
|
2915
|
+
const top = rect.top + window.scrollY, bottom = rect.bottom + window.scrollY;
|
|
2916
|
+
if (right <= 0 || left >= docW || bottom <= 0 || top >= docH) return "offscreen";
|
|
2917
|
+
const cx = rect.left + rect.width / 2, cy = rect.top + rect.height / 2;
|
|
2918
|
+
let clipped = false;
|
|
2919
|
+
for (let a = el.parentElement; a && a !== document.body; a = a.parentElement) {
|
|
2920
|
+
const s = getComputedStyle(a);
|
|
2921
|
+
const clipsX = /hidden|clip/.test(s.overflowX), clipsY = /hidden|clip/.test(s.overflowY);
|
|
2922
|
+
if (!clipsX && !clipsY) continue;
|
|
2923
|
+
const ar = a.getBoundingClientRect();
|
|
2924
|
+
const outside = clipsX && (cx < ar.left || cx > ar.right) || clipsY && (cy < ar.top || cy > ar.bottom);
|
|
2925
|
+
const tiny = clipsY && ar.height <= 2 || clipsX && ar.width <= 2;
|
|
2926
|
+
if (tiny) return "clipped";
|
|
2927
|
+
if (outside) clipped = true;
|
|
2928
|
+
}
|
|
2929
|
+
const inViewport = cx >= 0 && cy >= 0 && cx < window.innerWidth && cy < window.innerHeight;
|
|
2930
|
+
if (inViewport) {
|
|
2931
|
+
const hit = document.elementFromPoint(cx, cy);
|
|
2932
|
+
if (hit && (hit === el || el.contains(hit))) return null;
|
|
2933
|
+
return clipped ? "clipped" : null;
|
|
2934
|
+
}
|
|
2935
|
+
return null;
|
|
2936
|
+
}
|
|
2937
|
+
const baitName = /^(website|url|homepage|fax|honeypot|honey|hp|hp_[\w-]*|bot|bot_[\w-]*|trap|_gotcha|leave_?blank|do_?not_?fill)$/i;
|
|
2938
|
+
function looksLikeBait(el) {
|
|
2939
|
+
const name = el.getAttribute("name") || "", id = el.id || "";
|
|
2940
|
+
if (baitName.test(name) || baitName.test(id)) return true;
|
|
2941
|
+
const type = (el.getAttribute("type") || "text").toLowerCase();
|
|
2942
|
+
const textual = /^(text|email|url|tel|search|number)$/.test(type);
|
|
2943
|
+
return textual && el.getAttribute("tabindex") === "-1";
|
|
2944
|
+
}
|
|
2573
2945
|
function buildSelector(el) {
|
|
2574
2946
|
const path13 = [];
|
|
2575
2947
|
let current = el;
|
|
@@ -2612,6 +2984,11 @@ async function takeSnapshot(page, ctx, options) {
|
|
|
2612
2984
|
if (results.length >= maxElements2) break;
|
|
2613
2985
|
if (interactiveOnly2 && !isInteractive(el)) continue;
|
|
2614
2986
|
if (!isVisible(el)) continue;
|
|
2987
|
+
let suspect;
|
|
2988
|
+
if (isFillable(el)) {
|
|
2989
|
+
if (unreachableReason(el)) continue;
|
|
2990
|
+
if (looksLikeBait(el)) suspect = "honeypot?";
|
|
2991
|
+
}
|
|
2615
2992
|
const tag = el.tagName.toLowerCase();
|
|
2616
2993
|
const role = el.getAttribute("role") || "";
|
|
2617
2994
|
const type = el.type || "";
|
|
@@ -2624,7 +3001,8 @@ async function takeSnapshot(page, ctx, options) {
|
|
|
2624
3001
|
checked: el.checked || false,
|
|
2625
3002
|
disabled: el.disabled || false,
|
|
2626
3003
|
selector: buildSelector(el),
|
|
2627
|
-
isVisible: true
|
|
3004
|
+
isVisible: true,
|
|
3005
|
+
suspect
|
|
2628
3006
|
});
|
|
2629
3007
|
}
|
|
2630
3008
|
return results;
|
|
@@ -2645,6 +3023,7 @@ async function takeSnapshot(page, ctx, options) {
|
|
|
2645
3023
|
const state = [];
|
|
2646
3024
|
if (el.disabled) state.push("disabled");
|
|
2647
3025
|
if (el.checked) state.push("checked");
|
|
3026
|
+
if (el.suspect) state.push(el.suspect);
|
|
2648
3027
|
let description = "";
|
|
2649
3028
|
if (el.placeholder && el.name !== el.placeholder) description = `placeholder="${el.placeholder}"`;
|
|
2650
3029
|
if (el.type && !["text", "submit", "button", ""].includes(el.type)) {
|
|
@@ -3993,6 +4372,8 @@ async function executeAction(page, step, ctx, monitor) {
|
|
|
3993
4372
|
|
|
3994
4373
|
// src/lib/stale-monitor.ts
|
|
3995
4374
|
var DEFAULT_STALE_TIMEOUT_MS = 2e4;
|
|
4375
|
+
var PROBE_BUDGET_MS = 3e3;
|
|
4376
|
+
var UNRESPONSIVE_GRACE_MS = 8e3;
|
|
3996
4377
|
var StaleStateMonitor = class _StaleStateMonitor {
|
|
3997
4378
|
page;
|
|
3998
4379
|
timeoutMs;
|
|
@@ -4004,21 +4385,24 @@ var StaleStateMonitor = class _StaleStateMonitor {
|
|
|
4004
4385
|
}
|
|
4005
4386
|
async snapshot() {
|
|
4006
4387
|
try {
|
|
4007
|
-
const
|
|
4008
|
-
|
|
4388
|
+
const url = this.page.url();
|
|
4389
|
+
let unresponsive = false;
|
|
4390
|
+
const state = await withBudget(
|
|
4009
4391
|
this.page.evaluate(() => ({
|
|
4010
4392
|
documentReadyState: document.readyState,
|
|
4011
4393
|
bodyTextLength: document.body?.innerText?.length || 0,
|
|
4012
4394
|
elementCount: document.querySelectorAll("*").length
|
|
4013
|
-
}))
|
|
4014
|
-
|
|
4015
|
-
|
|
4016
|
-
|
|
4017
|
-
|
|
4018
|
-
|
|
4395
|
+
})),
|
|
4396
|
+
PROBE_BUDGET_MS,
|
|
4397
|
+
"state probe"
|
|
4398
|
+
).catch((e) => {
|
|
4399
|
+
unresponsive = e instanceof BudgetExceededError;
|
|
4400
|
+
return { documentReadyState: "unknown", bodyTextLength: 0, elementCount: 0 };
|
|
4401
|
+
});
|
|
4019
4402
|
return {
|
|
4020
4403
|
url,
|
|
4021
4404
|
...state,
|
|
4405
|
+
unresponsive,
|
|
4022
4406
|
timestamp: Date.now()
|
|
4023
4407
|
};
|
|
4024
4408
|
} catch {
|
|
@@ -4062,6 +4446,8 @@ var StaleStateMonitor = class _StaleStateMonitor {
|
|
|
4062
4446
|
return new Promise((resolve, reject) => {
|
|
4063
4447
|
let resolved = false;
|
|
4064
4448
|
let checkInterval = null;
|
|
4449
|
+
let probing = false;
|
|
4450
|
+
let unresponsiveSince = null;
|
|
4065
4451
|
const cleanup = () => {
|
|
4066
4452
|
resolved = true;
|
|
4067
4453
|
if (checkInterval) clearInterval(checkInterval);
|
|
@@ -4069,11 +4455,25 @@ var StaleStateMonitor = class _StaleStateMonitor {
|
|
|
4069
4455
|
this.timer = null;
|
|
4070
4456
|
};
|
|
4071
4457
|
checkInterval = setInterval(async () => {
|
|
4072
|
-
if (resolved) return;
|
|
4458
|
+
if (resolved || probing) return;
|
|
4073
4459
|
const elapsed = Date.now() - this.lastActivity;
|
|
4074
4460
|
if (elapsed < this.timeoutMs) return;
|
|
4461
|
+
probing = true;
|
|
4075
4462
|
try {
|
|
4076
4463
|
const currentSnapshot = await this.snapshot();
|
|
4464
|
+
if (resolved) return;
|
|
4465
|
+
if (currentSnapshot.unresponsive) {
|
|
4466
|
+
unresponsiveSince ??= Date.now();
|
|
4467
|
+
if (Date.now() - unresponsiveSince < UNRESPONSIVE_GRACE_MS) return;
|
|
4468
|
+
cleanup();
|
|
4469
|
+
reject(new StaleStateError(
|
|
4470
|
+
`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)`,
|
|
4471
|
+
this.timeoutMs,
|
|
4472
|
+
true
|
|
4473
|
+
));
|
|
4474
|
+
return;
|
|
4475
|
+
}
|
|
4476
|
+
unresponsiveSince = null;
|
|
4077
4477
|
if (_StaleStateMonitor.hasChanged(beforeSnapshot, currentSnapshot)) {
|
|
4078
4478
|
this.lastActivity = Date.now();
|
|
4079
4479
|
return;
|
|
@@ -4081,6 +4481,8 @@ var StaleStateMonitor = class _StaleStateMonitor {
|
|
|
4081
4481
|
} catch {
|
|
4082
4482
|
this.lastActivity = Date.now();
|
|
4083
4483
|
return;
|
|
4484
|
+
} finally {
|
|
4485
|
+
probing = false;
|
|
4084
4486
|
}
|
|
4085
4487
|
cleanup();
|
|
4086
4488
|
reject(new StaleStateError(
|
|
@@ -4104,10 +4506,13 @@ var StaleStateMonitor = class _StaleStateMonitor {
|
|
|
4104
4506
|
};
|
|
4105
4507
|
var StaleStateError = class extends Error {
|
|
4106
4508
|
timeoutMs;
|
|
4107
|
-
|
|
4509
|
+
/** True when the page stopped answering at all (vs. answering but not changing). */
|
|
4510
|
+
unresponsive;
|
|
4511
|
+
constructor(message, timeoutMs, unresponsive = false) {
|
|
4108
4512
|
super(message);
|
|
4109
4513
|
this.name = "StaleStateError";
|
|
4110
4514
|
this.timeoutMs = timeoutMs;
|
|
4515
|
+
this.unresponsive = unresponsive;
|
|
4111
4516
|
}
|
|
4112
4517
|
};
|
|
4113
4518
|
|
|
@@ -4318,10 +4723,14 @@ async function capturePageState(page, ctx, opts) {
|
|
|
4318
4723
|
url = page.url();
|
|
4319
4724
|
} catch {
|
|
4320
4725
|
}
|
|
4321
|
-
const title = await page.title().catch(() => "");
|
|
4726
|
+
const title = await withBudget(page.title(), 2e3, "title").catch(() => "");
|
|
4322
4727
|
if (!screenshot2) return { url, title };
|
|
4323
4728
|
try {
|
|
4324
|
-
const buf = await
|
|
4729
|
+
const buf = await withBudget(
|
|
4730
|
+
page.screenshot({ type: "jpeg", quality: 50, fullPage: false, timeout: 12e3 }),
|
|
4731
|
+
13e3,
|
|
4732
|
+
"screenshot"
|
|
4733
|
+
);
|
|
4325
4734
|
const screenshotUrl = saveScreenshot(buf, `${namePrefix}-${Date.now()}.jpg`, ctx.screenshotDir, ctx.publicUrl);
|
|
4326
4735
|
return { url, title, screenshotUrl };
|
|
4327
4736
|
} catch {
|
|
@@ -4693,10 +5102,37 @@ var ApiCapture = class {
|
|
|
4693
5102
|
await new Promise((r) => setTimeout(r, 100));
|
|
4694
5103
|
}
|
|
4695
5104
|
}
|
|
5105
|
+
/** Requests fired but not yet answered. */
|
|
5106
|
+
hasPending() {
|
|
5107
|
+
return this.pendingRequests.size > 0;
|
|
5108
|
+
}
|
|
4696
5109
|
getResults() {
|
|
4697
5110
|
return buildCapturedApi(this.requests);
|
|
4698
5111
|
}
|
|
5112
|
+
/** Every HTTP >= 400 response, compact, in capture order. */
|
|
5113
|
+
getServerErrors() {
|
|
5114
|
+
return serverErrorsFrom(this.requests);
|
|
5115
|
+
}
|
|
4699
5116
|
};
|
|
5117
|
+
var SERVER_ERROR_BODY_MAX = 600;
|
|
5118
|
+
function serverErrorsFrom(requests) {
|
|
5119
|
+
const out = [];
|
|
5120
|
+
for (const r of requests) {
|
|
5121
|
+
if (r.responseStatus < 400) continue;
|
|
5122
|
+
let body;
|
|
5123
|
+
if (r.responseBody !== void 0 && r.responseBody !== null) {
|
|
5124
|
+
const raw = typeof r.responseBody === "string" ? r.responseBody : JSON.stringify(r.responseBody);
|
|
5125
|
+
const flat = raw.replace(/\s+/g, " ").trim();
|
|
5126
|
+
body = flat.length > SERVER_ERROR_BODY_MAX ? flat.slice(0, SERVER_ERROR_BODY_MAX) + "\u2026" : flat;
|
|
5127
|
+
if (/^\s*<(!doctype|html)/i.test(raw)) {
|
|
5128
|
+
const m = raw.match(/<title[^>]*>([^<]*)<\/title>|<h1[^>]*>([^<]*)<\/h1>/i);
|
|
5129
|
+
body = m ? `[html] ${(m[1] || m[2] || "").trim()}` : "[html page]";
|
|
5130
|
+
}
|
|
5131
|
+
}
|
|
5132
|
+
out.push({ stepIndex: r.triggeredAtStep, method: r.method, url: r.url, status: r.responseStatus, body });
|
|
5133
|
+
}
|
|
5134
|
+
return out;
|
|
5135
|
+
}
|
|
4700
5136
|
function extractAuth(requests) {
|
|
4701
5137
|
const auth2 = { cookies: {}, tokens: {} };
|
|
4702
5138
|
for (const req of requests) {
|
|
@@ -4945,10 +5381,36 @@ function classifyError(err, step) {
|
|
|
4945
5381
|
if (step.type === "solve-captcha") return "captcha-unsolvable";
|
|
4946
5382
|
return "action-failed";
|
|
4947
5383
|
}
|
|
5384
|
+
async function refineError(page, step, ctx, errorType, message) {
|
|
5385
|
+
if (errorType !== "stale-state" && errorType !== "timeout") return { errorType, message };
|
|
5386
|
+
const raw = step.selector;
|
|
5387
|
+
if (typeof raw !== "string") return { errorType, message };
|
|
5388
|
+
let resolved;
|
|
5389
|
+
try {
|
|
5390
|
+
resolved = resolveSelector(raw, ctx);
|
|
5391
|
+
} catch {
|
|
5392
|
+
return { errorType, message };
|
|
5393
|
+
}
|
|
5394
|
+
try {
|
|
5395
|
+
const probe = page.evaluate((sel) => !!document.querySelector(sel), resolved);
|
|
5396
|
+
const budget = new Promise((r) => setTimeout(() => r(null), TIMING.FAILURE_PROBE));
|
|
5397
|
+
const found = await Promise.race([probe, budget]);
|
|
5398
|
+
if (found !== false) return { errorType, message };
|
|
5399
|
+
} catch {
|
|
5400
|
+
return { errorType, message };
|
|
5401
|
+
}
|
|
5402
|
+
const shown = resolved === raw ? `"${raw}"` : `"${raw}" (\u2192 ${resolved})`;
|
|
5403
|
+
return {
|
|
5404
|
+
errorType: "element-not-found",
|
|
5405
|
+
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})`
|
|
5406
|
+
};
|
|
5407
|
+
}
|
|
4948
5408
|
function isRetryable(errorType) {
|
|
4949
5409
|
return errorType === "stale-state" || errorType === "timeout" || errorType === "element-not-found";
|
|
4950
5410
|
}
|
|
4951
|
-
|
|
5411
|
+
var 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.";
|
|
5412
|
+
function getSuggestion(errorType, step, message = "") {
|
|
5413
|
+
if (message.includes("tab stopped responding")) return UNRESPONSIVE_SUGGESTION;
|
|
4952
5414
|
const usedEphemeralRef = typeof step.selector === "string" && step.selector.startsWith("@e");
|
|
4953
5415
|
if (usedEphemeralRef && (errorType === "element-not-found" || errorType === "timeout" || errorType === "stale-state")) {
|
|
4954
5416
|
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.";
|
|
@@ -4957,7 +5419,7 @@ function getSuggestion(errorType, step) {
|
|
|
4957
5419
|
case "stale-state":
|
|
4958
5420
|
return "The page stopped responding. The step may have triggered a very slow load or the server may be unreachable.";
|
|
4959
5421
|
case "element-not-found":
|
|
4960
|
-
return
|
|
5422
|
+
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.";
|
|
4961
5423
|
case "navigation-failed":
|
|
4962
5424
|
return "Navigation failed. The URL may be unreachable, blocked, or require authentication.";
|
|
4963
5425
|
case "captcha-unsolvable":
|
|
@@ -4975,6 +5437,9 @@ var PipelineRunner = class {
|
|
|
4975
5437
|
}
|
|
4976
5438
|
ctx;
|
|
4977
5439
|
hooks;
|
|
5440
|
+
/** Live progress, readable while run() is still pending — lets an outer
|
|
5441
|
+
* watchdog report WHICH step hung and what already finished. */
|
|
5442
|
+
progress = { stepIndex: -1, results: [] };
|
|
4978
5443
|
async run(initialPage, pipeline) {
|
|
4979
5444
|
const tracker = new TabTracker(initialPage.context(), initialPage);
|
|
4980
5445
|
try {
|
|
@@ -4992,6 +5457,7 @@ var PipelineRunner = class {
|
|
|
4992
5457
|
const continueOnError = opts.continueOnError ?? false;
|
|
4993
5458
|
const results = [];
|
|
4994
5459
|
const obstacles = [];
|
|
5460
|
+
this.progress.results = results;
|
|
4995
5461
|
const navStep = pipeline.steps.find((s) => s.type === "navigate");
|
|
4996
5462
|
try {
|
|
4997
5463
|
const host = new URL(navStep?.url || safePageUrl(initialPage) || "http://x").hostname;
|
|
@@ -5009,12 +5475,18 @@ var PipelineRunner = class {
|
|
|
5009
5475
|
if (capture) capture.start();
|
|
5010
5476
|
const finishCapture = async () => {
|
|
5011
5477
|
if (!capture) return void 0;
|
|
5478
|
+
await capture.drain(TIMING.CAPTURE_SETTLE, TIMING.CAPTURE_PENDING_MAX);
|
|
5012
5479
|
capture.stop();
|
|
5480
|
+
for (const se of capture.getServerErrors()) {
|
|
5481
|
+
const r = results.find((x) => x.stepIndex === se.stepIndex);
|
|
5482
|
+
if (r) (r.serverErrors ??= []).push(se);
|
|
5483
|
+
}
|
|
5013
5484
|
return capture.getResults();
|
|
5014
5485
|
};
|
|
5015
5486
|
for (let i = 0; i < pipeline.steps.length; i++) {
|
|
5016
5487
|
if (capture) capture.setStep(i);
|
|
5017
5488
|
const step = pipeline.steps[i];
|
|
5489
|
+
this.progress.stepIndex = i;
|
|
5018
5490
|
const page = tracker.active();
|
|
5019
5491
|
const urlBefore = safePageUrl(page);
|
|
5020
5492
|
const monitor = new StaleStateMonitor(page, staleTimeoutMs);
|
|
@@ -5034,9 +5506,10 @@ var PipelineRunner = class {
|
|
|
5034
5506
|
throw err;
|
|
5035
5507
|
}
|
|
5036
5508
|
const asError = err instanceof Error ? err : new Error(String(err));
|
|
5037
|
-
const
|
|
5509
|
+
const refined = await refineError(tracker.active(), step, this.ctx, classifyError(asError, step), asError.message);
|
|
5510
|
+
const errorType = refined.errorType;
|
|
5038
5511
|
const pageState = await capturePageState(tracker.active(), this.ctx, { screenshot: true });
|
|
5039
|
-
stepResult = failedStepResult(step,
|
|
5512
|
+
stepResult = failedStepResult(step, refined.message, Date.now() - startTime, i);
|
|
5040
5513
|
results.push(stepResult);
|
|
5041
5514
|
recordAnchor(step, false);
|
|
5042
5515
|
return {
|
|
@@ -5050,9 +5523,9 @@ var PipelineRunner = class {
|
|
|
5050
5523
|
failedAtStep: i,
|
|
5051
5524
|
failedStep: step,
|
|
5052
5525
|
errorType,
|
|
5053
|
-
message:
|
|
5526
|
+
message: refined.message,
|
|
5054
5527
|
pageState,
|
|
5055
|
-
suggestion: getSuggestion(errorType, step),
|
|
5528
|
+
suggestion: getSuggestion(errorType, step, refined.message),
|
|
5056
5529
|
retryable: isRetryable(errorType)
|
|
5057
5530
|
},
|
|
5058
5531
|
durationMs: Date.now() - startTime,
|
|
@@ -5086,8 +5559,16 @@ var PipelineRunner = class {
|
|
|
5086
5559
|
}
|
|
5087
5560
|
results.push(stepResult);
|
|
5088
5561
|
if (!stepResult.ok && !continueOnError) {
|
|
5562
|
+
const refined = await refineError(
|
|
5563
|
+
tracker.active(),
|
|
5564
|
+
step,
|
|
5565
|
+
this.ctx,
|
|
5566
|
+
classifyError(new Error(stepResult.error || ""), step),
|
|
5567
|
+
stepResult.error || "Step failed"
|
|
5568
|
+
);
|
|
5569
|
+
stepResult.error = refined.message;
|
|
5570
|
+
const errorType = refined.errorType;
|
|
5089
5571
|
const pageState = await capturePageState(tracker.active(), this.ctx, { screenshot: true });
|
|
5090
|
-
const errorType = classifyError(new Error(stepResult.error || ""), step);
|
|
5091
5572
|
return {
|
|
5092
5573
|
ok: false,
|
|
5093
5574
|
completedSteps: i,
|
|
@@ -5101,7 +5582,7 @@ var PipelineRunner = class {
|
|
|
5101
5582
|
errorType,
|
|
5102
5583
|
message: stepResult.error || "Step failed",
|
|
5103
5584
|
pageState,
|
|
5104
|
-
suggestion: getSuggestion(errorType, step),
|
|
5585
|
+
suggestion: getSuggestion(errorType, step, refined.message),
|
|
5105
5586
|
retryable: isRetryable(errorType)
|
|
5106
5587
|
},
|
|
5107
5588
|
durationMs: Date.now() - startTime,
|
|
@@ -5111,7 +5592,7 @@ var PipelineRunner = class {
|
|
|
5111
5592
|
if (step.type === "navigate" && continueOnObstacle) {
|
|
5112
5593
|
const obstacleStart = Date.now();
|
|
5113
5594
|
const obstaclePage = tracker.active();
|
|
5114
|
-
const obstacle = await detectObstacles(obstaclePage);
|
|
5595
|
+
const obstacle = await withBudget(detectObstacles(obstaclePage), 1e4, "obstacle check").catch(() => null);
|
|
5115
5596
|
if (obstacle) {
|
|
5116
5597
|
const resolution = await resolveObstacle(obstaclePage, obstacle, this.ctx, monitor);
|
|
5117
5598
|
obstacles.push({
|
|
@@ -5852,7 +6333,7 @@ var CdpRelay = class {
|
|
|
5852
6333
|
return await Promise.race([
|
|
5853
6334
|
first,
|
|
5854
6335
|
new Promise((_, reject) => {
|
|
5855
|
-
const t = setTimeout(() => reject(new Error("screenshot timed out (no compositor frame)")),
|
|
6336
|
+
const t = setTimeout(() => reject(new Error("screenshot timed out (no compositor frame)")), 5e3);
|
|
5856
6337
|
t.unref?.();
|
|
5857
6338
|
})
|
|
5858
6339
|
]);
|
|
@@ -5911,6 +6392,24 @@ var CdpRelay = class {
|
|
|
5911
6392
|
|
|
5912
6393
|
// src/lib/execution/pipeline-executor.ts
|
|
5913
6394
|
var log18 = createLogger("iframer");
|
|
6395
|
+
function blankPipelineResult(pipeline, modeUsed, error) {
|
|
6396
|
+
return {
|
|
6397
|
+
ok: false,
|
|
6398
|
+
completedSteps: 0,
|
|
6399
|
+
totalSteps: pipeline.steps.length,
|
|
6400
|
+
results: [],
|
|
6401
|
+
obstacles: [],
|
|
6402
|
+
error: {
|
|
6403
|
+
failedAtStep: 0,
|
|
6404
|
+
failedStep: pipeline.steps[0],
|
|
6405
|
+
pageState: { url: "", title: "" },
|
|
6406
|
+
...error
|
|
6407
|
+
},
|
|
6408
|
+
durationMs: 0,
|
|
6409
|
+
modeUsed,
|
|
6410
|
+
finalState: { url: "", title: "" }
|
|
6411
|
+
};
|
|
6412
|
+
}
|
|
5914
6413
|
var PipelineExecutor = class {
|
|
5915
6414
|
constructor(deps) {
|
|
5916
6415
|
this.deps = deps;
|
|
@@ -5948,8 +6447,24 @@ var PipelineExecutor = class {
|
|
|
5948
6447
|
const forcedMode = opts.mode;
|
|
5949
6448
|
const autoEscalate = opts.autoEscalate !== false;
|
|
5950
6449
|
const instanceId = opts.instanceId || DEFAULT_INSTANCE;
|
|
6450
|
+
if (forcedMode === "extension") {
|
|
6451
|
+
return blankPipelineResult(pipeline, "extension", {
|
|
6452
|
+
errorType: "action-failed",
|
|
6453
|
+
message: 'mode="extension" requires options.tabId (the real-Chrome tab to drive).',
|
|
6454
|
+
suggestion: "Call the `tabs` tool (or GET /extension/tabs) to list open tabs, then pass that tab id. Nothing was launched.",
|
|
6455
|
+
retryable: false
|
|
6456
|
+
});
|
|
6457
|
+
}
|
|
5951
6458
|
const firstNav = pipeline.steps.find((s) => s.type === "navigate");
|
|
5952
6459
|
const domain = firstNav ? new URL(firstNav.url).hostname : null;
|
|
6460
|
+
if (!firstNav && !this.deps.daemon.findLiveMode(instanceId)) {
|
|
6461
|
+
return blankPipelineResult(pipeline, forcedMode || "headless", {
|
|
6462
|
+
errorType: "action-failed",
|
|
6463
|
+
message: `No navigate step and no live browser for instanceId "${instanceId}" to act on.`,
|
|
6464
|
+
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.",
|
|
6465
|
+
retryable: false
|
|
6466
|
+
});
|
|
6467
|
+
}
|
|
5953
6468
|
const availableModes = this.deps.availableModes();
|
|
5954
6469
|
const liveMode = forcedMode ? null : this.deps.daemon.findLiveMode(instanceId);
|
|
5955
6470
|
let mode;
|
|
@@ -6013,6 +6528,7 @@ var PipelineExecutor = class {
|
|
|
6013
6528
|
const startTime = Date.now();
|
|
6014
6529
|
const relay = new CdpRelay(tabId, clientId, pipeline.options?.focus);
|
|
6015
6530
|
let browser;
|
|
6531
|
+
let runner;
|
|
6016
6532
|
try {
|
|
6017
6533
|
await relay.start();
|
|
6018
6534
|
browser = await import_playwright_core.chromium.connectOverCDP(relay.httpEndpoint(), { timeout: 3e4 });
|
|
@@ -6025,7 +6541,8 @@ var PipelineExecutor = class {
|
|
|
6025
6541
|
if (!page) throw new Error("no page available for the tab (is it still open?)");
|
|
6026
6542
|
const ctx = this.deps.refStore.makeContext(userId, token);
|
|
6027
6543
|
if (this.pendingElicitOtp) ctx.elicitOtp = this.pendingElicitOtp;
|
|
6028
|
-
|
|
6544
|
+
ctx.driver = "extension";
|
|
6545
|
+
runner = new PipelineRunner(ctx);
|
|
6029
6546
|
const typeChars = pipeline.steps.reduce((n, s) => {
|
|
6030
6547
|
const v = s.value;
|
|
6031
6548
|
return (s.type === "human-type" || s.type === "type-code") && typeof v === "string" ? n + v.length : n;
|
|
@@ -6042,7 +6559,7 @@ var PipelineExecutor = class {
|
|
|
6042
6559
|
watchdog = setTimeout(
|
|
6043
6560
|
() => reject(
|
|
6044
6561
|
new Error(
|
|
6045
|
-
`pipeline exceeded ${Math.round(capMs / 1e3)}s \u2014
|
|
6562
|
+
`pipeline exceeded ${Math.round(capMs / 1e3)}s \u2014 a call into the tab never returned.`
|
|
6046
6563
|
)
|
|
6047
6564
|
),
|
|
6048
6565
|
capMs
|
|
@@ -6066,23 +6583,26 @@ var PipelineExecutor = class {
|
|
|
6066
6583
|
} catch (err) {
|
|
6067
6584
|
const msg = getErrorMessage(err);
|
|
6068
6585
|
const stalled = msg.includes("pipeline exceeded");
|
|
6586
|
+
const done = runner ? runner.progress.results.filter((r) => r.ok) : [];
|
|
6587
|
+
const hungAt = stalled && runner && runner.progress.stepIndex >= 0 ? runner.progress.stepIndex : 0;
|
|
6588
|
+
const hungStep = pipeline.steps[hungAt];
|
|
6069
6589
|
return {
|
|
6070
6590
|
ok: false,
|
|
6071
|
-
completedSteps: 0,
|
|
6591
|
+
completedSteps: stalled ? done.length : 0,
|
|
6072
6592
|
totalSteps: pipeline.steps.length,
|
|
6073
|
-
results: [],
|
|
6593
|
+
results: stalled ? done : [],
|
|
6074
6594
|
finalState: { url: "", title: "" },
|
|
6075
6595
|
obstacles: [],
|
|
6076
6596
|
durationMs: Date.now() - startTime,
|
|
6077
6597
|
modeUsed: "extension",
|
|
6078
6598
|
error: {
|
|
6079
|
-
failedAtStep:
|
|
6080
|
-
failedStep:
|
|
6081
|
-
errorType: "action-failed",
|
|
6082
|
-
message: `Extension mode failed: ${msg}`,
|
|
6599
|
+
failedAtStep: hungAt,
|
|
6600
|
+
failedStep: hungStep,
|
|
6601
|
+
errorType: stalled ? "stale-state" : "action-failed",
|
|
6602
|
+
message: stalled ? `Extension mode: step ${hungAt} (${hungStep?.type}) never returned; ${msg}` : `Extension mode failed: ${msg}`,
|
|
6083
6603
|
pageState: { url: "", title: "" },
|
|
6084
|
-
suggestion: stalled ?
|
|
6085
|
-
retryable:
|
|
6604
|
+
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.",
|
|
6605
|
+
retryable: stalled
|
|
6086
6606
|
}
|
|
6087
6607
|
};
|
|
6088
6608
|
} finally {
|
|
@@ -6637,6 +7157,11 @@ var Iframer = class {
|
|
|
6637
7157
|
listInstances() {
|
|
6638
7158
|
return this.daemon.instancesInfo();
|
|
6639
7159
|
}
|
|
7160
|
+
/** Close browsers that never navigated anywhere. See
|
|
7161
|
+
* BrowserDaemon.sweepBlankInstances — called from the server's reap tick. */
|
|
7162
|
+
sweepBlankBrowsers() {
|
|
7163
|
+
return this.daemon.sweepBlankInstances();
|
|
7164
|
+
}
|
|
6640
7165
|
/** Kill all browser instances and reset state. Next execute call will
|
|
6641
7166
|
* launch a fresh browser automatically — no manual restart needed. */
|
|
6642
7167
|
async restartBrowser() {
|
|
@@ -7053,6 +7578,10 @@ var reapTimer = setInterval(async () => {
|
|
|
7053
7578
|
await reapOrphanBrowsers();
|
|
7054
7579
|
} catch {
|
|
7055
7580
|
}
|
|
7581
|
+
try {
|
|
7582
|
+
iframer.sweepBlankBrowsers();
|
|
7583
|
+
} catch {
|
|
7584
|
+
}
|
|
7056
7585
|
const idleMs = Date.now() - lastActivity;
|
|
7057
7586
|
if (idleMs > IDLE_EXIT_MS && !iframer.browserHealth().alive && !extensionBridge.hasClients()) {
|
|
7058
7587
|
gracefulShutdown(`idle for ${Math.round(idleMs / 6e4)}min with no browsers`);
|