@proagentstore/cli 0.3.8 → 0.4.1
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.
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { createConnection } from "@playwright/mcp";
|
|
2
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
3
|
+
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
|
4
|
+
/**
|
|
5
|
+
* Hosts the INDUSTRY-STANDARD `@playwright/mcp` server in the runner and an in-process
|
|
6
|
+
* MCP client to drive it. Every browser action goes through the standard Playwright MCP
|
|
7
|
+
* tools (browser_snapshot, browser_click, browser_type, browser_select_option,
|
|
8
|
+
* browser_fill_form, browser_file_upload, …) — no hand-rolled browser code. When the lib
|
|
9
|
+
* updates, we get its fixes for free. The cloud brain calls these same tools over the relay.
|
|
10
|
+
*/
|
|
11
|
+
export class McpRuntime {
|
|
12
|
+
server;
|
|
13
|
+
client;
|
|
14
|
+
async start(opts) {
|
|
15
|
+
if (this.client)
|
|
16
|
+
return;
|
|
17
|
+
const browser = opts.cdpEndpoint
|
|
18
|
+
? { cdpEndpoint: opts.cdpEndpoint }
|
|
19
|
+
: { userDataDir: opts.userDataDir, isolated: opts.isolated, launchOptions: { headless: opts.headless ?? false } };
|
|
20
|
+
// The runner is a trusted local process uploading the user's OWN résumé, which
|
|
21
|
+
// lives under the runner's data dir (outside the CWD). Lift the file-root guard
|
|
22
|
+
// (meant to stop an LLM reading arbitrary host files) so browser_file_upload can
|
|
23
|
+
// attach it — the runner, not the model, chooses the path.
|
|
24
|
+
this.server = await createConnection({ browser, allowUnrestrictedFileAccess: true });
|
|
25
|
+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
26
|
+
await this.server.connect(serverTransport);
|
|
27
|
+
this.client = new Client({ name: "pags-runner", version: "1.0.0" });
|
|
28
|
+
await this.client.connect(clientTransport);
|
|
29
|
+
}
|
|
30
|
+
/** The standard Playwright MCP tool schemas — advertised to the cloud brain verbatim. */
|
|
31
|
+
async listTools() {
|
|
32
|
+
const res = await this.client.listTools();
|
|
33
|
+
return res.tools;
|
|
34
|
+
}
|
|
35
|
+
async callTool(name, args = {}) {
|
|
36
|
+
return (await this.client.callTool({ name, arguments: args }));
|
|
37
|
+
}
|
|
38
|
+
/** Text of the last tool result (the standard tools return their output as text content). */
|
|
39
|
+
textOf(res) {
|
|
40
|
+
return (res.content || []).map((c) => c.text ?? "").join("\n");
|
|
41
|
+
}
|
|
42
|
+
async stop() {
|
|
43
|
+
await this.client?.close().catch(() => undefined);
|
|
44
|
+
await this.server?.close().catch(() => undefined);
|
|
45
|
+
this.client = undefined;
|
|
46
|
+
this.server = undefined;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
|
-
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { createRequire } from "node:module";
|
|
4
4
|
import { basename, dirname, join, resolve } from "node:path";
|
|
5
5
|
import { pathToFileURL } from "node:url";
|
|
6
6
|
import { captureScreenshotDataUrl, challengeSolved, detectHumanChallenge } from "./challenge.js";
|
|
7
7
|
import { resolveRealChromeProfileDir, seedProfileCopy } from "./browser-profile.js";
|
|
8
|
-
import {
|
|
8
|
+
import { McpRuntime } from "./mcp-runtime.js";
|
|
9
9
|
import { HumanHandoffError, RunnerInputError } from "./errors.js";
|
|
10
10
|
import { RunnerStore } from "./store.js";
|
|
11
11
|
import { CodingRuntime } from "./coding/runtime.js";
|
|
@@ -42,6 +42,16 @@ export class LocalRunner {
|
|
|
42
42
|
activePage = null;
|
|
43
43
|
fileAttachArmed = new WeakSet();
|
|
44
44
|
applyResumePath = null;
|
|
45
|
+
/**
|
|
46
|
+
* The INDUSTRY-STANDARD `@playwright/mcp` server, attached over CDP to the same
|
|
47
|
+
* real-profile Chrome the takeover machinery drives. Every brain-issued browser
|
|
48
|
+
* action runs through its standard tools — no hand-rolled action code. Lazily
|
|
49
|
+
* started once the browser is up (see {@link getMcp}).
|
|
50
|
+
*/
|
|
51
|
+
mcp = null;
|
|
52
|
+
cdpEndpoint = null;
|
|
53
|
+
/** The profile dir Chrome was actually launched into (holds DevToolsActivePort). */
|
|
54
|
+
launchedProfileDir = null;
|
|
45
55
|
store;
|
|
46
56
|
/** Local tmux coding sessions (the second runtime — AgentCoder port). */
|
|
47
57
|
coding;
|
|
@@ -145,8 +155,12 @@ export class LocalRunner {
|
|
|
145
155
|
}
|
|
146
156
|
async close() {
|
|
147
157
|
this.coding.closeAll();
|
|
158
|
+
await this.mcp?.stop().catch(() => undefined);
|
|
159
|
+
this.mcp = null;
|
|
160
|
+
this.cdpEndpoint = null;
|
|
148
161
|
await this.browserContext?.close();
|
|
149
162
|
this.browserContext = null;
|
|
163
|
+
this.launchedProfileDir = null;
|
|
150
164
|
}
|
|
151
165
|
async runTask(id) {
|
|
152
166
|
const task = this.requireTask(id);
|
|
@@ -517,7 +531,10 @@ export class LocalRunner {
|
|
|
517
531
|
viewport: null,
|
|
518
532
|
// Light anti-detection: drop the most obvious automation tell so fewer
|
|
519
533
|
// CAPTCHAs trigger in the first place (the human still solves the rest).
|
|
520
|
-
|
|
534
|
+
// --remote-debugging-port=0 → Chrome picks a free CDP port and writes it to
|
|
535
|
+
// DevToolsActivePort in the profile dir; the standard @playwright/mcp server
|
|
536
|
+
// attaches to that endpoint so it drives THIS same real-profile browser.
|
|
537
|
+
args: ["--disable-blink-features=AutomationControlled", "--start-maximized", "--window-size=1512,982", "--remote-debugging-port=0"],
|
|
521
538
|
};
|
|
522
539
|
// Prefer the real Chrome build (better TLS/fingerprint → fewer CAPTCHAs);
|
|
523
540
|
// fall back to bundled Chromium if Chrome isn't installed. Disable with
|
|
@@ -548,10 +565,13 @@ export class LocalRunner {
|
|
|
548
565
|
channel: "chrome",
|
|
549
566
|
args: [...baseOpts.args, "--profile-directory=Default"],
|
|
550
567
|
});
|
|
568
|
+
this.launchedProfileDir = seededDir;
|
|
551
569
|
console.log(`[runner] launched a private copy of your real Chrome profile (signed-in sessions seeded from "${realProfileDir.profile}")`);
|
|
552
570
|
}
|
|
553
571
|
else {
|
|
554
|
-
|
|
572
|
+
const dedicated = join(this.config.dataDir, "chrome-profile");
|
|
573
|
+
this.browserContext = await playwright.chromium.launchPersistentContext(dedicated, { ...baseOpts, channel: "chrome" });
|
|
574
|
+
this.launchedProfileDir = dedicated;
|
|
555
575
|
}
|
|
556
576
|
}
|
|
557
577
|
catch (err) {
|
|
@@ -560,6 +580,7 @@ export class LocalRunner {
|
|
|
560
580
|
console.warn(`[runner] real-profile launch failed (${msg}); falling back to a dedicated profile. Fully quit Chrome (Cmd+Q) to use your real profile.`);
|
|
561
581
|
}
|
|
562
582
|
this.browserContext = await playwright.chromium.launchPersistentContext(profileDir, baseOpts);
|
|
583
|
+
this.launchedProfileDir = profileDir;
|
|
563
584
|
}
|
|
564
585
|
await this.browserContext
|
|
565
586
|
.addInitScript(() => {
|
|
@@ -608,10 +629,102 @@ export class LocalRunner {
|
|
|
608
629
|
this.activePage = page;
|
|
609
630
|
return page;
|
|
610
631
|
}
|
|
632
|
+
/** The CDP endpoint of the launched Chrome (from DevToolsActivePort in its profile). */
|
|
633
|
+
async readCdpEndpoint() {
|
|
634
|
+
if (this.cdpEndpoint)
|
|
635
|
+
return this.cdpEndpoint;
|
|
636
|
+
const dir = this.launchedProfileDir;
|
|
637
|
+
if (!dir)
|
|
638
|
+
throw new Error("browser context not launched yet");
|
|
639
|
+
const portFile = join(dir, "DevToolsActivePort");
|
|
640
|
+
for (let i = 0; i < 50; i++) {
|
|
641
|
+
if (existsSync(portFile)) {
|
|
642
|
+
const line = readFileSync(portFile, "utf8").split("\n")[0]?.trim();
|
|
643
|
+
if (line) {
|
|
644
|
+
this.cdpEndpoint = `http://127.0.0.1:${line}`;
|
|
645
|
+
return this.cdpEndpoint;
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
649
|
+
}
|
|
650
|
+
throw new Error("could not read Chrome CDP port (DevToolsActivePort missing)");
|
|
651
|
+
}
|
|
652
|
+
/**
|
|
653
|
+
* The standard `@playwright/mcp` server, attached over CDP to the runner's own
|
|
654
|
+
* real-profile Chrome. Lazily started; every browser action + snapshot goes
|
|
655
|
+
* through its standard tools. Because it shares the browser over CDP, actions it
|
|
656
|
+
* performs land on the exact page the takeover/screencast machinery drives.
|
|
657
|
+
*/
|
|
658
|
+
async getMcp() {
|
|
659
|
+
if (this.mcp)
|
|
660
|
+
return this.mcp;
|
|
661
|
+
await this.getBrowserContext();
|
|
662
|
+
const cdpEndpoint = await this.readCdpEndpoint();
|
|
663
|
+
const mcp = new McpRuntime();
|
|
664
|
+
await mcp.start({ cdpEndpoint });
|
|
665
|
+
this.mcp = mcp;
|
|
666
|
+
return mcp;
|
|
667
|
+
}
|
|
668
|
+
/** Pull the aria tree (with [ref=eNN]) out of a standard browser_snapshot result. */
|
|
669
|
+
extractSnapshot(text) {
|
|
670
|
+
const yaml = text.match(/```yaml\n([\s\S]*?)```/);
|
|
671
|
+
if (yaml)
|
|
672
|
+
return yaml[1].trimEnd();
|
|
673
|
+
const i = text.indexOf("Page Snapshot:");
|
|
674
|
+
return i >= 0 ? text.slice(i + "Page Snapshot:".length).trim() : text.trim();
|
|
675
|
+
}
|
|
676
|
+
/** Concise feedback from a tool result — drop the big page snapshot (the brain
|
|
677
|
+
* gets a fresh one next turn); keep the action confirmation / any error text. */
|
|
678
|
+
conciseFeedback(text) {
|
|
679
|
+
const cut = text.search(/###\s*Page state|- Page Snapshot:|```yaml/);
|
|
680
|
+
const head = (cut >= 0 ? text.slice(0, cut) : text).replace(/```[a-z]*\n?|```/g, " ").replace(/\s+/g, " ").trim();
|
|
681
|
+
return head.slice(0, 400);
|
|
682
|
+
}
|
|
683
|
+
/** The snapshot ref the brain must target the element by (standard-tool `target`). */
|
|
684
|
+
refOf(action) {
|
|
685
|
+
const ref = (action.ref || "").trim();
|
|
686
|
+
if (!ref)
|
|
687
|
+
throw new RunnerInputError(`the "${action.action}" action needs the element's snapshot ref (e.g. "e42")`);
|
|
688
|
+
return ref;
|
|
689
|
+
}
|
|
690
|
+
/** Map one brain BrowserAction onto the industry-standard @playwright/mcp tool. */
|
|
691
|
+
async callBrowserTool(mcp, a) {
|
|
692
|
+
const label = a.name || a.role || a.action;
|
|
693
|
+
switch (a.action) {
|
|
694
|
+
case "navigate":
|
|
695
|
+
return mcp.callTool("browser_navigate", { url: a.url });
|
|
696
|
+
case "type":
|
|
697
|
+
return mcp.callTool("browser_type", { element: label, target: this.refOf(a), text: a.text ?? "" });
|
|
698
|
+
// A checkbox/radio is just a click at the standard-tool level.
|
|
699
|
+
case "click":
|
|
700
|
+
case "check":
|
|
701
|
+
return mcp.callTool("browser_click", { element: label, target: this.refOf(a) });
|
|
702
|
+
case "upload": {
|
|
703
|
+
// Standard two-step: clicking the upload control opens the file chooser,
|
|
704
|
+
// which puts the @playwright/mcp server into its file-chooser modal state;
|
|
705
|
+
// browser_file_upload then resolves that state with the résumé path.
|
|
706
|
+
await mcp.callTool("browser_click", { element: label, target: this.refOf(a) });
|
|
707
|
+
if (!this.applyResumePath)
|
|
708
|
+
throw new RunnerInputError("no résumé file available to upload");
|
|
709
|
+
return mcp.callTool("browser_file_upload", { paths: [this.applyResumePath] });
|
|
710
|
+
}
|
|
711
|
+
case "select":
|
|
712
|
+
return mcp.callTool("browser_select_option", { element: label, target: this.refOf(a), values: [a.text ?? ""] });
|
|
713
|
+
case "key":
|
|
714
|
+
return mcp.callTool("browser_press_key", { key: a.key || "Enter" });
|
|
715
|
+
case "scroll":
|
|
716
|
+
return mcp.callTool("browser_evaluate", { function: `() => window.scrollBy(0, ${Math.round(a.dy ?? 600)})` });
|
|
717
|
+
case "wait":
|
|
718
|
+
return mcp.callTool("browser_wait_for", { time: Math.min(5, Math.max(0.1, (a.ms ?? 1000) / 1000)) });
|
|
719
|
+
default:
|
|
720
|
+
throw new RunnerInputError(`unknown action: ${a.action}`);
|
|
721
|
+
}
|
|
722
|
+
}
|
|
611
723
|
/**
|
|
612
|
-
* What the remote brain "sees":
|
|
613
|
-
*
|
|
614
|
-
*
|
|
724
|
+
* What the remote brain "sees": the standard `@playwright/mcp` page snapshot — a
|
|
725
|
+
* compact ARIA tree with roles, accessible names, values, states, and a stable
|
|
726
|
+
* [ref=eNN] on each interactive element. The brain targets elements by that ref;
|
|
727
|
+
* because {@link browserAct} routes through the SAME mcp server, the refs resolve.
|
|
615
728
|
*/
|
|
616
729
|
async browserSnapshot(taskId) {
|
|
617
730
|
// If the user pressed Stop, the task is cancelled — tell the brain to halt.
|
|
@@ -620,15 +733,9 @@ export class LocalRunner {
|
|
|
620
733
|
}
|
|
621
734
|
const page = await this.getActivePage();
|
|
622
735
|
await page.waitForLoadState("domcontentloaded", { timeout: 5_000 }).catch(() => undefined);
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
// the aria-ref selector (instead of role+name, which collides when two fields share a
|
|
627
|
-
// label). This is exactly the call Playwright's own MCP uses. Falls back to the plain
|
|
628
|
-
// aria snapshot if the option is ever unavailable.
|
|
629
|
-
const aria = page;
|
|
630
|
-
const full = (await aria.ariaSnapshot({ mode: "ai" }).catch(() => "")) ||
|
|
631
|
-
(await page.locator("body").ariaSnapshot().catch(() => ""));
|
|
736
|
+
const mcp = await this.getMcp();
|
|
737
|
+
const res = await mcp.callTool("browser_snapshot");
|
|
738
|
+
const full = this.extractSnapshot(mcp.textOf(res));
|
|
632
739
|
const MAX = 16_000;
|
|
633
740
|
const snapshot = full.length > MAX ? `${full.slice(0, MAX)}\n… [snapshot truncated]` : full;
|
|
634
741
|
return {
|
|
@@ -640,9 +747,10 @@ export class LocalRunner {
|
|
|
640
747
|
};
|
|
641
748
|
}
|
|
642
749
|
/**
|
|
643
|
-
* Perform one brain-issued action
|
|
644
|
-
*
|
|
645
|
-
*
|
|
750
|
+
* Perform one brain-issued action via the standard `@playwright/mcp` tools
|
|
751
|
+
* (browser_click / browser_type / browser_select_option / …), targeting the
|
|
752
|
+
* element by its snapshot ref. A tool-level failure is thrown so the workflow
|
|
753
|
+
* surfaces it to the brain as an `error` (which drives its self-correction).
|
|
646
754
|
*/
|
|
647
755
|
async browserAct(action, resumePath) {
|
|
648
756
|
const page = await this.getActivePage();
|
|
@@ -656,9 +764,31 @@ export class LocalRunner {
|
|
|
656
764
|
}
|
|
657
765
|
if (this.applyResumePath)
|
|
658
766
|
this.armFileAutoAttach(page, this.applyResumePath);
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
767
|
+
const mcp = await this.getMcp();
|
|
768
|
+
const res = await this.callBrowserTool(mcp, action);
|
|
769
|
+
const text = mcp.textOf(res).trim();
|
|
770
|
+
// A native page dialog (alert/confirm/beforeunload) puts the standard server
|
|
771
|
+
// into a modal state that blocks EVERY other tool until it's handled. The brain
|
|
772
|
+
// has no dialog vocabulary (the old runtime relied on Playwright's default
|
|
773
|
+
// auto-dismiss, which @playwright/mcp overrides), so clear it transparently and
|
|
774
|
+
// report it as a successful, informative step — the brain proceeds on the next
|
|
775
|
+
// snapshot instead of thrashing into a needless human handoff.
|
|
776
|
+
if (res.isError && /modal state/i.test(text) && /dialog/i.test(text)) {
|
|
777
|
+
const dialogMsg = (text.match(/dialog with message "([^"]*)"/)?.[1] || "").slice(0, 160);
|
|
778
|
+
await mcp.callTool("browser_handle_dialog", { accept: true }).catch(() => undefined);
|
|
779
|
+
await page.waitForTimeout(400).catch(() => undefined);
|
|
780
|
+
const settled = await this.getActivePage();
|
|
781
|
+
await settled.waitForLoadState("domcontentloaded", { timeout: 6_000 }).catch(() => undefined);
|
|
782
|
+
return {
|
|
783
|
+
ok: true,
|
|
784
|
+
url: settled.url(),
|
|
785
|
+
title: await settled.title().catch(() => ""),
|
|
786
|
+
challenge: await detectHumanChallenge(settled),
|
|
787
|
+
feedback: dialogMsg ? `a native dialog was accepted: "${dialogMsg}"` : "a native dialog was accepted",
|
|
788
|
+
};
|
|
789
|
+
}
|
|
790
|
+
if (res.isError)
|
|
791
|
+
throw new RunnerInputError(this.conciseFeedback(text) || `${action.action} failed`);
|
|
662
792
|
// A click may trigger SPA navigation or open a new tab/popup. Give it a beat
|
|
663
793
|
// to settle and follow the (possibly new) active page, so the next snapshot
|
|
664
794
|
// reflects the new state — not the element the brain just clicked.
|
|
@@ -672,36 +802,14 @@ export class LocalRunner {
|
|
|
672
802
|
if (action.action === "click" || action.action === "navigate" || action.action === "key") {
|
|
673
803
|
await active.waitForLoadState("networkidle", { timeout: 3_500 }).catch(() => undefined);
|
|
674
804
|
}
|
|
675
|
-
// Read back what a write action actually did (real field value + any validation
|
|
676
|
-
// error) so the brain gets semantic feedback, not just "Playwright didn't throw".
|
|
677
|
-
const feedback = await inspectField(active, action).catch(() => "");
|
|
678
805
|
return {
|
|
679
806
|
ok: true,
|
|
680
807
|
url: active.url(),
|
|
681
808
|
title: await active.title().catch(() => ""),
|
|
682
809
|
challenge: await detectHumanChallenge(active),
|
|
683
|
-
feedback:
|
|
810
|
+
feedback: this.conciseFeedback(text) || undefined,
|
|
684
811
|
};
|
|
685
812
|
}
|
|
686
|
-
/**
|
|
687
|
-
* Click an element through escalating fallbacks: normal → force (bypass
|
|
688
|
-
* actionability) → scroll-into-view + click the element's CENTER COORDINATES
|
|
689
|
-
* with the mouse. The coordinate click defeats custom-styled controls (hidden
|
|
690
|
-
* inputs, overlays) that swallow locator clicks. Returns false only if the
|
|
691
|
-
* element can't be located/positioned at all.
|
|
692
|
-
*/
|
|
693
|
-
async clickRobustly(page, loc) {
|
|
694
|
-
if (await loc.click({ timeout: 6_000 }).then(() => true).catch(() => false))
|
|
695
|
-
return true;
|
|
696
|
-
if (await loc.click({ force: true, timeout: 4_000 }).then(() => true).catch(() => false))
|
|
697
|
-
return true;
|
|
698
|
-
await loc.scrollIntoViewIfNeeded({ timeout: 3_000 }).catch(() => undefined);
|
|
699
|
-
const box = await loc.boundingBox().catch(() => null);
|
|
700
|
-
if (!box)
|
|
701
|
-
return false;
|
|
702
|
-
await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2).catch(() => undefined);
|
|
703
|
-
return true;
|
|
704
|
-
}
|
|
705
813
|
// ── Agent-driven application lifecycle (called by the remote Workflow brain) ──
|
|
706
814
|
/** Append a decision/event to the agent task's activity (the console trace). */
|
|
707
815
|
browserEvent(taskId, type, message, data) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@proagentstore/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "CLI for creating, publishing, and running ProAgentStore agents",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -30,6 +30,8 @@
|
|
|
30
30
|
"typecheck": "tsc --noEmit"
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
|
+
"@modelcontextprotocol/sdk": "1.29.0",
|
|
34
|
+
"@playwright/mcp": "0.0.77",
|
|
33
35
|
"chalk": "^5.6.2",
|
|
34
36
|
"commander": "^13.0.0",
|
|
35
37
|
"playwright": "^1.60.0"
|
|
@@ -1,357 +0,0 @@
|
|
|
1
|
-
import { existsSync } from "node:fs";
|
|
2
|
-
import { resolve } from "node:path";
|
|
3
|
-
import { RunnerInputError } from "./errors.js";
|
|
4
|
-
/**
|
|
5
|
-
* Enter text and VERIFY it actually took, escalating through the best-practice ladder
|
|
6
|
-
* for masked / framework-controlled inputs (intl-tel, React-controlled, etc.):
|
|
7
|
-
* 1) fill() 2) pressSequentially() + blur() 3) native value setter + dispatch events.
|
|
8
|
-
* Returns true once the field holds the value. Generic — no site/widget-specific logic.
|
|
9
|
-
*/
|
|
10
|
-
async function typeRobustly(loc, text) {
|
|
11
|
-
const norm = (s) => s.replace(/\s+/g, "");
|
|
12
|
-
const took = async () => {
|
|
13
|
-
const v = await loc.inputValue({ timeout: 1_500 }).catch(() => null);
|
|
14
|
-
if (v == null)
|
|
15
|
-
return false;
|
|
16
|
-
return v === text || norm(v) === norm(text) || (norm(text).length > 3 && norm(v).includes(norm(text)));
|
|
17
|
-
};
|
|
18
|
-
// 1. Playwright fill — fires input/change for most inputs.
|
|
19
|
-
if (await loc.fill(text, { timeout: 6_000 }).then(() => true).catch(() => false)) {
|
|
20
|
-
if (await took())
|
|
21
|
-
return true;
|
|
22
|
-
}
|
|
23
|
-
// 2. Real per-character typing + blur — for masks/typeaheads that only validate on key events.
|
|
24
|
-
try {
|
|
25
|
-
await loc.click({ timeout: 3_000 });
|
|
26
|
-
await loc.fill("", { timeout: 2_000 }).catch(() => undefined); // clear first
|
|
27
|
-
await loc.pressSequentially(text, { delay: 25, timeout: 8_000 });
|
|
28
|
-
await loc.blur().catch(() => undefined);
|
|
29
|
-
if (await took())
|
|
30
|
-
return true;
|
|
31
|
-
}
|
|
32
|
-
catch {
|
|
33
|
-
/* fall through */
|
|
34
|
-
}
|
|
35
|
-
// 3. Native value setter + dispatched events — for React/Vue-controlled inputs where
|
|
36
|
-
// fill() sets the DOM value but the framework's onChange never fires.
|
|
37
|
-
try {
|
|
38
|
-
await loc.evaluate((el, val) => {
|
|
39
|
-
const input = el;
|
|
40
|
-
const proto = input instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
|
|
41
|
-
const setter = Object.getOwnPropertyDescriptor(proto, "value")?.set;
|
|
42
|
-
if (setter)
|
|
43
|
-
setter.call(input, val);
|
|
44
|
-
else
|
|
45
|
-
input.value = val;
|
|
46
|
-
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
47
|
-
input.dispatchEvent(new Event("change", { bubbles: true }));
|
|
48
|
-
input.dispatchEvent(new Event("blur", { bubbles: true }));
|
|
49
|
-
}, text);
|
|
50
|
-
if (await took())
|
|
51
|
-
return true;
|
|
52
|
-
}
|
|
53
|
-
catch {
|
|
54
|
-
/* fall through */
|
|
55
|
-
}
|
|
56
|
-
return false;
|
|
57
|
-
}
|
|
58
|
-
/**
|
|
59
|
-
* Execute one selector-free browser action — address by ARIA role + accessible
|
|
60
|
-
* name, with robust fallbacks for custom comboboxes, typeaheads, fuzzy <select>
|
|
61
|
-
* matches, hidden checkboxes, and hidden file inputs. Pure over `page`; the
|
|
62
|
-
* runner passes its clickRobustly helper. The caller handles post-action settle.
|
|
63
|
-
*/
|
|
64
|
-
export async function performBrowserAction(page, action, clickRobustly, resumeFile) {
|
|
65
|
-
const locate = () => {
|
|
66
|
-
// Prefer the stable snapshot ref (aria-ref) — points at the exact element, so two
|
|
67
|
-
// fields sharing a label (e.g. a phone "Country" code and an address "Country")
|
|
68
|
-
// are never confused. Fall back to role+name for older snapshots.
|
|
69
|
-
if (action.ref)
|
|
70
|
-
return page.locator(`aria-ref=${action.ref}`);
|
|
71
|
-
const role = action.role;
|
|
72
|
-
let loc = role
|
|
73
|
-
? page.getByRole(role, action.name ? { name: action.name } : undefined)
|
|
74
|
-
: page.getByText(action.name ?? "", { exact: false });
|
|
75
|
-
loc = typeof action.nth === "number" ? loc.nth(action.nth) : loc.first();
|
|
76
|
-
return loc;
|
|
77
|
-
};
|
|
78
|
-
switch (action.action) {
|
|
79
|
-
case "navigate":
|
|
80
|
-
if (!action.url || !/^https?:\/\//.test(action.url))
|
|
81
|
-
throw new RunnerInputError("navigate requires an http(s) url");
|
|
82
|
-
await page.goto(action.url, { waitUntil: "domcontentloaded", timeout: 30_000 });
|
|
83
|
-
break;
|
|
84
|
-
case "type": {
|
|
85
|
-
const text = String(action.text ?? "");
|
|
86
|
-
const loc = locate();
|
|
87
|
-
// Verified ladder (fill → pressSequentially+blur → setter+dispatch) — makes the
|
|
88
|
-
// value actually take on masked / framework-controlled inputs.
|
|
89
|
-
if (await typeRobustly(loc, text))
|
|
90
|
-
break;
|
|
91
|
-
// The field may be a combobox or typeahead rather than a bare textbox.
|
|
92
|
-
if (action.name && (await page.getByRole("combobox", { name: action.name }).fill(text, { timeout: 3_000 }).then(() => true).catch(() => false)))
|
|
93
|
-
break;
|
|
94
|
-
if (action.name && (await page.getByLabel(action.name).fill(text, { timeout: 3_000 }).then(() => true).catch(() => false)))
|
|
95
|
-
break;
|
|
96
|
-
await clickRobustly(page, loc);
|
|
97
|
-
await page.keyboard.type(text, { delay: 15 }).catch(() => undefined);
|
|
98
|
-
break;
|
|
99
|
-
}
|
|
100
|
-
case "click":
|
|
101
|
-
if (!(await clickRobustly(page, locate())))
|
|
102
|
-
throw new RunnerInputError("could not click the target");
|
|
103
|
-
break;
|
|
104
|
-
case "select": {
|
|
105
|
-
const value = String(action.text ?? "");
|
|
106
|
-
const loc = locate();
|
|
107
|
-
const pickOption = async () => {
|
|
108
|
-
// Custom dropdowns render options async/with animation — WAIT for the list
|
|
109
|
-
// to appear before clicking (else you click an overlay or nothing).
|
|
110
|
-
await page.locator('[role="option"], [role="listbox"] li, ul[role="listbox"] > *').first().waitFor({ state: "visible", timeout: 2_000 }).catch(() => undefined);
|
|
111
|
-
const opt = page.getByRole("option", { name: value, exact: false }).first();
|
|
112
|
-
if ((await opt.count().catch(() => 0)) > 0)
|
|
113
|
-
return opt.click({ timeout: 2_500 }).then(() => true).catch(() => false);
|
|
114
|
-
// Fallback: any visible option/li whose text contains the value.
|
|
115
|
-
const alt = page.locator('[role="option"], li').filter({ hasText: value }).first();
|
|
116
|
-
if ((await alt.count().catch(() => 0)) > 0)
|
|
117
|
-
return alt.click({ timeout: 2_500 }).then(() => true).catch(() => false);
|
|
118
|
-
return false;
|
|
119
|
-
};
|
|
120
|
-
// 1. Native <select>: exact label/value, then a fuzzy (case/punctuation
|
|
121
|
-
// -insensitive) match so "Decline to self-identify" hits the option
|
|
122
|
-
// "Decline To Self Identify".
|
|
123
|
-
if (await loc.selectOption({ label: value }, { timeout: 4_000 }).then(() => true).catch(() => false))
|
|
124
|
-
break;
|
|
125
|
-
if (await loc.selectOption(value, { timeout: 2_500 }).then(() => true).catch(() => false))
|
|
126
|
-
break;
|
|
127
|
-
const fuzzy = await loc.evaluate((el, want) => {
|
|
128
|
-
if (!(el instanceof HTMLSelectElement))
|
|
129
|
-
return false;
|
|
130
|
-
const norm = (s) => s.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
131
|
-
const w = norm(want);
|
|
132
|
-
const opt = Array.from(el.options).find((o) => { const t = norm(o.textContent || ""); return t === w || (w.length > 3 && (t.includes(w) || w.includes(t))); });
|
|
133
|
-
if (!opt)
|
|
134
|
-
return false;
|
|
135
|
-
el.value = opt.value;
|
|
136
|
-
el.dispatchEvent(new Event("input", { bubbles: true }));
|
|
137
|
-
el.dispatchEvent(new Event("change", { bubbles: true }));
|
|
138
|
-
return true;
|
|
139
|
-
}, value).catch(() => false);
|
|
140
|
-
if (fuzzy)
|
|
141
|
-
break;
|
|
142
|
-
// 2. Custom combobox / typeahead: open it, try a visible matching option.
|
|
143
|
-
await clickRobustly(page, loc);
|
|
144
|
-
await page.waitForTimeout(350).catch(() => undefined);
|
|
145
|
-
if (await pickOption())
|
|
146
|
-
break;
|
|
147
|
-
// 3. Typeahead: type to filter, then pick the suggestion — by click, else
|
|
148
|
-
// keyboard (ArrowDown+Enter), which beats clicking a dropdown that
|
|
149
|
-
// closes on blur.
|
|
150
|
-
await page.keyboard.type(value, { delay: 15 }).catch(() => undefined);
|
|
151
|
-
await page.waitForTimeout(550).catch(() => undefined);
|
|
152
|
-
if (await pickOption())
|
|
153
|
-
break;
|
|
154
|
-
await page.keyboard.press("ArrowDown").catch(() => undefined);
|
|
155
|
-
await page.keyboard.press("Enter").catch(() => undefined);
|
|
156
|
-
break;
|
|
157
|
-
}
|
|
158
|
-
case "check": {
|
|
159
|
-
const loc = locate();
|
|
160
|
-
// Custom checkboxes hide the real <input> (opacity:0 / behind a label /
|
|
161
|
-
// a div[role=checkbox]). Try Playwright's check, then force, then a
|
|
162
|
-
// direct DOM tick matched by the checkbox's label text.
|
|
163
|
-
if (await loc.check({ timeout: 5_000 }).then(() => true).catch(() => false))
|
|
164
|
-
break;
|
|
165
|
-
if (await loc.check({ force: true, timeout: 3_000 }).then(() => true).catch(() => false))
|
|
166
|
-
break;
|
|
167
|
-
const ticked = await page
|
|
168
|
-
.evaluate((rawName) => {
|
|
169
|
-
const norm = (s) => (s || "").toLowerCase().replace(/\s+/g, " ").trim();
|
|
170
|
-
const needle = norm(rawName).slice(0, 30);
|
|
171
|
-
const boxes = Array.from(document.querySelectorAll('input[type="checkbox"], [role="checkbox"]'));
|
|
172
|
-
const labelOf = (b) => {
|
|
173
|
-
const forId = b.id ? document.querySelector(`label[for="${b.id}"]`)?.textContent : "";
|
|
174
|
-
return norm(b.closest("label")?.textContent || b.getAttribute("aria-label") || forId || b.parentElement?.textContent || "");
|
|
175
|
-
};
|
|
176
|
-
const tick = (b) => {
|
|
177
|
-
if (b instanceof HTMLInputElement) {
|
|
178
|
-
b.checked = true;
|
|
179
|
-
b.dispatchEvent(new Event("input", { bubbles: true }));
|
|
180
|
-
b.dispatchEvent(new Event("change", { bubbles: true }));
|
|
181
|
-
}
|
|
182
|
-
else {
|
|
183
|
-
b.setAttribute("aria-checked", "true");
|
|
184
|
-
}
|
|
185
|
-
b.click?.();
|
|
186
|
-
};
|
|
187
|
-
const match = boxes.find((b) => { const l = labelOf(b); return needle && (l.includes(needle) || (l.length > 12 && needle.includes(l.slice(0, 12)))); });
|
|
188
|
-
const target = match || (boxes.length === 1 ? boxes[0] : undefined);
|
|
189
|
-
if (!target)
|
|
190
|
-
return false;
|
|
191
|
-
tick(target);
|
|
192
|
-
return true;
|
|
193
|
-
}, action.name ?? "")
|
|
194
|
-
.catch(() => false);
|
|
195
|
-
if (ticked)
|
|
196
|
-
break;
|
|
197
|
-
if (!(await clickRobustly(page, locate())))
|
|
198
|
-
throw new RunnerInputError("could not find/tick the checkbox");
|
|
199
|
-
break;
|
|
200
|
-
}
|
|
201
|
-
case "upload": {
|
|
202
|
-
// Always attach via Playwright — never a native dialog. Handles both a
|
|
203
|
-
// direct <input type=file> (set files on it) and a styled "Upload"
|
|
204
|
-
// button that opens a native chooser (intercept the filechooser).
|
|
205
|
-
// The LLM doesn't know the runner's local path, so prefer the résumé the
|
|
206
|
-
// runner already resolved (downloaded from the platform); fall back to an
|
|
207
|
-
// explicit action.file only if it's a real local file.
|
|
208
|
-
const explicit = action.file ? resolve(String(action.file)) : "";
|
|
209
|
-
const file = resumeFile && existsSync(resumeFile)
|
|
210
|
-
? resumeFile
|
|
211
|
-
: explicit && existsSync(explicit) ? explicit : "";
|
|
212
|
-
if (!file)
|
|
213
|
-
throw new RunnerInputError("no résumé available to upload — upload one in the console (Knowledge → Résumé)");
|
|
214
|
-
let done = false;
|
|
215
|
-
// 1. Label-associated input (some forms).
|
|
216
|
-
if (action.name) {
|
|
217
|
-
done = await page.getByLabel(action.name).setInputFiles(file, { timeout: 4_000 }).then(() => true).catch(() => false);
|
|
218
|
-
}
|
|
219
|
-
// 2. The real <input type=file> directly — most ATS (Greenhouse, Lever…)
|
|
220
|
-
// hide it behind a styled "Attach" button; setInputFiles works on a
|
|
221
|
-
// hidden input and fires the change event, no native dialog.
|
|
222
|
-
if (!done) {
|
|
223
|
-
done = await page.locator('input[type="file"]').first().setInputFiles(file, { timeout: 4_000 }).then(() => true).catch(() => false);
|
|
224
|
-
}
|
|
225
|
-
// 3. Styled uploader with no input at all: click the trigger + intercept
|
|
226
|
-
// the native file chooser.
|
|
227
|
-
if (!done) {
|
|
228
|
-
const chooserP = page.waitForEvent("filechooser", { timeout: 8_000 }).catch(() => null);
|
|
229
|
-
await locate().click({ timeout: 8_000 }).catch(() => undefined);
|
|
230
|
-
const chooser = await chooserP;
|
|
231
|
-
if (chooser)
|
|
232
|
-
await chooser.setFiles(file);
|
|
233
|
-
else
|
|
234
|
-
throw new RunnerInputError("no file upload control found for upload action");
|
|
235
|
-
}
|
|
236
|
-
break;
|
|
237
|
-
}
|
|
238
|
-
case "key":
|
|
239
|
-
await page.keyboard.press(String(action.key ?? "Enter"));
|
|
240
|
-
break;
|
|
241
|
-
case "scroll":
|
|
242
|
-
await page.mouse.wheel(0, action.dy ?? 600);
|
|
243
|
-
break;
|
|
244
|
-
case "wait":
|
|
245
|
-
await page.waitForTimeout(Math.min(5_000, action.ms ?? 1_000));
|
|
246
|
-
break;
|
|
247
|
-
default: {
|
|
248
|
-
const name = String(action.action || "");
|
|
249
|
-
// The brain sometimes invents click variants (triple_click / double_click
|
|
250
|
-
// to select a field's text before retyping). Handle them generically
|
|
251
|
-
// rather than failing the whole task — clickCount selects the text.
|
|
252
|
-
if (/click/i.test(name)) {
|
|
253
|
-
const loc = locate();
|
|
254
|
-
const clickCount = /triple/i.test(name) ? 3 : /double|dbl/i.test(name) ? 2 : 1;
|
|
255
|
-
if (!(await loc.click({ clickCount, timeout: 6_000 }).then(() => true).catch(() => false))) {
|
|
256
|
-
await clickRobustly(page, loc);
|
|
257
|
-
}
|
|
258
|
-
break;
|
|
259
|
-
}
|
|
260
|
-
throw new RunnerInputError(`Unsupported action "${name}". Use one of: click, type, select, check, upload, navigate, scroll, key, wait. To clear or replace a field, use "type" — it overwrites the existing value.`);
|
|
261
|
-
}
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
/**
|
|
265
|
-
* After a WRITE action (type/select/check), read back what the field actually
|
|
266
|
-
* holds now + any validation error rendered near it. This is the semantic
|
|
267
|
-
* feedback the brain otherwise lacks: `fill()` succeeding doesn't mean the value
|
|
268
|
-
* "took" (masked/intl widgets concatenate; validators reject a format). Returns a
|
|
269
|
-
* short human string for the action log, or "" when there's nothing notable.
|
|
270
|
-
* Never throws.
|
|
271
|
-
*/
|
|
272
|
-
export async function inspectField(page, action) {
|
|
273
|
-
if (action.action !== "type" && action.action !== "select" && action.action !== "check")
|
|
274
|
-
return "";
|
|
275
|
-
if (!action.name && !action.ref)
|
|
276
|
-
return "";
|
|
277
|
-
try {
|
|
278
|
-
const role = action.role;
|
|
279
|
-
let loc = action.ref
|
|
280
|
-
? page.locator(`aria-ref=${action.ref}`)
|
|
281
|
-
: role
|
|
282
|
-
? page.getByRole(role, action.name ? { name: action.name } : undefined)
|
|
283
|
-
: page.getByText(action.name ?? "", { exact: false });
|
|
284
|
-
if (!action.ref)
|
|
285
|
-
loc = typeof action.nth === "number" ? loc.nth(action.nth) : loc.first();
|
|
286
|
-
const el = await loc.elementHandle({ timeout: 1_500 }).catch(() => null);
|
|
287
|
-
if (!el)
|
|
288
|
-
return "";
|
|
289
|
-
const info = await el
|
|
290
|
-
.evaluate((node) => {
|
|
291
|
-
const e = node;
|
|
292
|
-
const raw = typeof e.value === "string" ? e.value : (e.getAttribute("aria-checked") ?? e.textContent ?? "");
|
|
293
|
-
const value = String(raw).trim().slice(0, 120);
|
|
294
|
-
const invalid = e.getAttribute("aria-invalid") === "true" || (typeof e.matches === "function" && e.matches(":invalid"));
|
|
295
|
-
let err = "";
|
|
296
|
-
const rx = /invalid|required|must|valid|error|format|enter a|please/i;
|
|
297
|
-
const describedby = e.getAttribute("aria-describedby");
|
|
298
|
-
if (describedby) {
|
|
299
|
-
for (const id of describedby.split(/\s+/)) {
|
|
300
|
-
const d = document.getElementById(id);
|
|
301
|
-
const t = (d?.textContent || "").trim();
|
|
302
|
-
if (t && rx.test(t)) {
|
|
303
|
-
err = t;
|
|
304
|
-
break;
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
|
-
}
|
|
308
|
-
if (!err) {
|
|
309
|
-
const scope = e.closest("[class*=field], [class*=form-group], [class*=form-item], fieldset") || e.parentElement;
|
|
310
|
-
const cand = scope?.querySelector('[role="alert"], [class*=error i], [class*=invalid i], [class*=danger i], [class*=help-block i]');
|
|
311
|
-
const t = (cand?.textContent || "").trim();
|
|
312
|
-
if (t && rx.test(t) && t.length < 180)
|
|
313
|
-
err = t;
|
|
314
|
-
}
|
|
315
|
-
const disabled = e.hasAttribute("disabled") || e.getAttribute("aria-disabled") === "true" || e.hasAttribute("readonly");
|
|
316
|
-
return { value, invalid, err: err.replace(/\s+/g, " ").slice(0, 180), tag: e.tagName.toLowerCase(), html: (e.outerHTML || "").replace(/\s+/g, " ").slice(0, 500), disabled };
|
|
317
|
-
})
|
|
318
|
-
.catch(() => null);
|
|
319
|
-
if (!info)
|
|
320
|
-
return "";
|
|
321
|
-
const typed = String(action.text ?? "").trim();
|
|
322
|
-
const stuck = !!typed && info.value === typed; // our value actually took
|
|
323
|
-
const parts = [];
|
|
324
|
-
// Report the field's real value when it differs from what we sent, or when the
|
|
325
|
-
// field is authoritatively invalid.
|
|
326
|
-
if (info.value && (info.invalid || !stuck)) {
|
|
327
|
-
parts.push(`"${action.name}" now reads "${info.value}"${typed && info.value !== typed ? ` (you sent "${typed}")` : ""}`);
|
|
328
|
-
}
|
|
329
|
-
// Flag REJECTED ONLY on an authoritative per-field signal (aria-invalid / :invalid)
|
|
330
|
-
// or when our value clearly did NOT take. A nearby error while the value stuck is
|
|
331
|
-
// often stale (widgets show a "format" error mid-type that clears on blur/submit)
|
|
332
|
-
// or belongs to another field — reporting it as REJECTED sent the brain into a
|
|
333
|
-
// false-retry loop on a value that was actually accepted.
|
|
334
|
-
if (info.invalid || (typed && !stuck)) {
|
|
335
|
-
if (info.disabled) {
|
|
336
|
-
// A disabled/read-only control can't be changed — it's almost always already
|
|
337
|
-
// set correctly. Tell the brain to move on instead of fixating on it.
|
|
338
|
-
parts.unshift(`ℹ "${action.name}" is DISABLED / read-only — it is likely already set correctly; do NOT keep trying, move on to other fields.`);
|
|
339
|
-
}
|
|
340
|
-
else {
|
|
341
|
-
if (info.err)
|
|
342
|
-
parts.unshift(`⚠ "${action.name}" REJECTED: "${info.err}"`);
|
|
343
|
-
else if (info.invalid)
|
|
344
|
-
parts.unshift(`⚠ "${action.name}" is marked invalid`);
|
|
345
|
-
// Value didn't take / field invalid → show the widget's real DOM so the brain
|
|
346
|
-
// can pick the right interaction (custom dropdown → click to open + click the
|
|
347
|
-
// option by text; masked input → a different shape) instead of blindly retrying.
|
|
348
|
-
if (info.html)
|
|
349
|
-
parts.push(`DOM: <${info.tag}> ${info.html}`);
|
|
350
|
-
}
|
|
351
|
-
}
|
|
352
|
-
return parts.join("; ");
|
|
353
|
-
}
|
|
354
|
-
catch {
|
|
355
|
-
return "";
|
|
356
|
-
}
|
|
357
|
-
}
|
|
@@ -1,71 +0,0 @@
|
|
|
1
|
-
const rand = (min, max) => min + Math.random() * (max - min);
|
|
2
|
-
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
3
|
-
// easeInOutQuad — slow start, fast middle, slow approach (how a hand decelerates onto a target).
|
|
4
|
-
const ease = (t) => (t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2);
|
|
5
|
-
function cubicBezier(p0, p1, p2, p3, t) {
|
|
6
|
-
const u = 1 - t;
|
|
7
|
-
return {
|
|
8
|
-
x: u * u * u * p0.x + 3 * u * u * t * p1.x + 3 * u * t * t * p2.x + t * t * t * p3.x,
|
|
9
|
-
y: u * u * u * p0.y + 3 * u * u * t * p1.y + 3 * u * t * t * p2.y + t * t * t * p3.y,
|
|
10
|
-
};
|
|
11
|
-
}
|
|
12
|
-
/** Move the cursor from `from` to `to` along a randomized human-like arc. */
|
|
13
|
-
export async function humanMoveTo(cdp, from, to) {
|
|
14
|
-
const dist = Math.hypot(to.x - from.x, to.y - from.y);
|
|
15
|
-
const steps = Math.max(12, Math.min(42, Math.round(dist / 9)));
|
|
16
|
-
// Control points pushed off the straight line so the path bows like a real
|
|
17
|
-
// hand's arc — bigger arc for longer travel.
|
|
18
|
-
const off = Math.min(90, dist * 0.3);
|
|
19
|
-
const c1 = {
|
|
20
|
-
x: from.x + (to.x - from.x) * 0.33 + rand(-off, off),
|
|
21
|
-
y: from.y + (to.y - from.y) * 0.33 + rand(-off, off),
|
|
22
|
-
};
|
|
23
|
-
const c2 = {
|
|
24
|
-
x: from.x + (to.x - from.x) * 0.66 + rand(-off, off),
|
|
25
|
-
y: from.y + (to.y - from.y) * 0.66 + rand(-off, off),
|
|
26
|
-
};
|
|
27
|
-
for (let i = 1; i <= steps; i++) {
|
|
28
|
-
const p = cubicBezier(from, c1, c2, to, ease(i / steps));
|
|
29
|
-
await cdp.send("Input.dispatchMouseEvent", {
|
|
30
|
-
type: "mouseMoved",
|
|
31
|
-
x: p.x + rand(-0.6, 0.6),
|
|
32
|
-
y: p.y + rand(-0.6, 0.6),
|
|
33
|
-
});
|
|
34
|
-
await sleep(rand(5, 15));
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
/** Click `to` with a human approach + natural press/release dwell. */
|
|
38
|
-
export async function humanClickAt(cdp, from, to) {
|
|
39
|
-
await humanMoveTo(cdp, from, to);
|
|
40
|
-
await sleep(rand(40, 130));
|
|
41
|
-
await cdp.send("Input.dispatchMouseEvent", { type: "mousePressed", x: to.x, y: to.y, button: "left", buttons: 1, clickCount: 1 });
|
|
42
|
-
await sleep(rand(40, 100));
|
|
43
|
-
await cdp.send("Input.dispatchMouseEvent", { type: "mouseReleased", x: to.x, y: to.y, button: "left", buttons: 0, clickCount: 1 });
|
|
44
|
-
}
|
|
45
|
-
/** Pick a natural target point inside a box (not dead-center) + an off-screen-ish start. */
|
|
46
|
-
export function targetIn(box) {
|
|
47
|
-
const to = {
|
|
48
|
-
x: box.x + box.width / 2 + rand(-box.width * 0.2, box.width * 0.2),
|
|
49
|
-
y: box.y + box.height / 2 + rand(-box.height * 0.25, box.height * 0.25),
|
|
50
|
-
};
|
|
51
|
-
const from = { x: to.x + rand(-260, -40), y: to.y - rand(60, 260) };
|
|
52
|
-
return { from, to };
|
|
53
|
-
}
|
|
54
|
-
/**
|
|
55
|
-
* Best-effort humanized approach to an element before the caller's real click,
|
|
56
|
-
* so the cursor arrives the way a hand would. Never throws — humanization must
|
|
57
|
-
* not break the underlying action.
|
|
58
|
-
*/
|
|
59
|
-
export async function humanApproach(page, box) {
|
|
60
|
-
if (!box)
|
|
61
|
-
return;
|
|
62
|
-
try {
|
|
63
|
-
const cdp = await page.context().newCDPSession(page);
|
|
64
|
-
const { from, to } = targetIn(box);
|
|
65
|
-
await humanMoveTo(cdp, from, to);
|
|
66
|
-
await cdp.detach().catch(() => undefined);
|
|
67
|
-
}
|
|
68
|
-
catch {
|
|
69
|
-
// humanization is an enhancement, never a hard dependency
|
|
70
|
-
}
|
|
71
|
-
}
|