feat: add remote CDP browser support
This commit is contained in:
@@ -26,6 +26,7 @@
|
|||||||
- Skills: add `things-mac` (Things 3 CLI) for read/search plus add/update via URL scheme.
|
- Skills: add `things-mac` (Things 3 CLI) for read/search plus add/update via URL scheme.
|
||||||
- Tests: add a Docker-based onboarding E2E harness.
|
- Tests: add a Docker-based onboarding E2E harness.
|
||||||
- Tests: harden wizard E2E flows for reset, providers, skills, and remote non-interactive runs.
|
- Tests: harden wizard E2E flows for reset, providers, skills, and remote non-interactive runs.
|
||||||
|
- Browser tools: add remote CDP URL support, Linux launcher options (`executablePath`, `noSandbox`), and surface `cdpUrl` in status.
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
- Skills: switch imsg installer to brew tap formula.
|
- Skills: switch imsg installer to brew tap formula.
|
||||||
|
|||||||
@@ -28,6 +28,13 @@ Add a dedicated settings section (preferably under **Skills** or its own "Browse
|
|||||||
- Interpreted as the base URL of the local/remote browser-control server.
|
- Interpreted as the base URL of the local/remote browser-control server.
|
||||||
- If the URL host is not loopback, Clawdis must **not** attempt to launch a local
|
- If the URL host is not loopback, Clawdis must **not** attempt to launch a local
|
||||||
browser; it only connects.
|
browser; it only connects.
|
||||||
|
- **CDP URL** (`default: controlUrl + 1`)
|
||||||
|
- Base URL for Chrome DevTools Protocol (e.g. `http://127.0.0.1:18792`).
|
||||||
|
- Set this to a non-loopback host to attach the local control server to a remote
|
||||||
|
Chrome/Chromium CDP endpoint (SSH/Tailscale tunnel recommended).
|
||||||
|
- If the CDP URL host is non-loopback, clawd does **not** auto-launch a local browser.
|
||||||
|
- If you tunnel a remote CDP to `localhost`, set **Attach to existing only** to
|
||||||
|
avoid accidentally launching a local browser.
|
||||||
- **Accent color** (`default: #FF4500`, "lobster-orange")
|
- **Accent color** (`default: #FF4500`, "lobster-orange")
|
||||||
- Used to theme the clawd browser profile (best-effort) and to tint UI indicators
|
- Used to theme the clawd browser profile (best-effort) and to tint UI indicators
|
||||||
in Clawdis.
|
in Clawdis.
|
||||||
@@ -36,6 +43,8 @@ Optional (advanced, can be hidden behind Debug initially):
|
|||||||
- **Use headless browser** (`default: off`)
|
- **Use headless browser** (`default: off`)
|
||||||
- **Attach to existing only** (`default: off`) — if on, never launch; only connect if
|
- **Attach to existing only** (`default: off`) — if on, never launch; only connect if
|
||||||
already running.
|
already running.
|
||||||
|
- **Browser executable path** (override, optional)
|
||||||
|
- **No sandbox** (`default: off`) — adds `--no-sandbox` + `--disable-setuid-sandbox`
|
||||||
|
|
||||||
### Port convention
|
### Port convention
|
||||||
|
|
||||||
@@ -68,7 +77,7 @@ internal detail.
|
|||||||
- The agent must be able to enumerate and target tabs deterministically (by
|
- The agent must be able to enumerate and target tabs deterministically (by
|
||||||
stable `targetId` or equivalent), not "last tab".
|
stable `targetId` or equivalent), not "last tab".
|
||||||
|
|
||||||
## Browser selection (macOS)
|
## Browser selection (macOS + Linux)
|
||||||
|
|
||||||
On startup (when enabled + local URL), Clawdis chooses the browser executable
|
On startup (when enabled + local URL), Clawdis chooses the browser executable
|
||||||
in this order:
|
in this order:
|
||||||
@@ -76,9 +85,14 @@ in this order:
|
|||||||
2) **Chromium** (if installed)
|
2) **Chromium** (if installed)
|
||||||
3) **Google Chrome** (fallback)
|
3) **Google Chrome** (fallback)
|
||||||
|
|
||||||
Implementation detail: detection is by existence of the `.app` bundle under
|
Linux:
|
||||||
`/Applications` (and optionally `~/Applications`), then using the resolved
|
- Looks for `google-chrome` / `chromium` in common system paths.
|
||||||
executable path.
|
- Use **Browser executable path** to force a specific binary.
|
||||||
|
|
||||||
|
Implementation detail:
|
||||||
|
- macOS: detection is by existence of the `.app` bundle under `/Applications`
|
||||||
|
(and optionally `~/Applications`), then using the resolved executable path.
|
||||||
|
- Linux: common `/usr/bin`/`/snap/bin` paths.
|
||||||
|
|
||||||
Rationale:
|
Rationale:
|
||||||
- Canary/Chromium are easy to visually distinguish from the user's daily driver.
|
- Canary/Chromium are easy to visually distinguish from the user's daily driver.
|
||||||
@@ -205,6 +219,7 @@ Notes:
|
|||||||
- The control server must bind to loopback only by default (`127.0.0.1`) unless the
|
- The control server must bind to loopback only by default (`127.0.0.1`) unless the
|
||||||
user explicitly configures a non-loopback URL.
|
user explicitly configures a non-loopback URL.
|
||||||
- Never reuse or copy the user's default Chrome profile.
|
- Never reuse or copy the user's default Chrome profile.
|
||||||
|
- Remote CDP endpoints should be tunneled or protected; CDP is highly privileged.
|
||||||
|
|
||||||
## Non-goals (for the first cut)
|
## Non-goals (for the first cut)
|
||||||
|
|
||||||
|
|||||||
@@ -462,6 +462,7 @@ Clawdis can start a **dedicated, isolated** Chrome/Chromium instance for clawd a
|
|||||||
Defaults:
|
Defaults:
|
||||||
- enabled: `true`
|
- enabled: `true`
|
||||||
- control URL: `http://127.0.0.1:18791` (CDP uses `18792`)
|
- control URL: `http://127.0.0.1:18791` (CDP uses `18792`)
|
||||||
|
- CDP URL: `http://127.0.0.1:18792` (control URL + 1)
|
||||||
- profile color: `#FF4500` (lobster-orange)
|
- profile color: `#FF4500` (lobster-orange)
|
||||||
- Note: the control server is started by the running gateway (Clawdis.app menubar, or `clawdis gateway`).
|
- Note: the control server is started by the running gateway (Clawdis.app menubar, or `clawdis gateway`).
|
||||||
|
|
||||||
@@ -470,10 +471,13 @@ Defaults:
|
|||||||
browser: {
|
browser: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
controlUrl: "http://127.0.0.1:18791",
|
controlUrl: "http://127.0.0.1:18791",
|
||||||
|
// cdpUrl: "http://127.0.0.1:18792", // override for remote CDP
|
||||||
color: "#FF4500",
|
color: "#FF4500",
|
||||||
// Advanced:
|
// Advanced:
|
||||||
// headless: false,
|
// headless: false,
|
||||||
// attachOnly: false,
|
// noSandbox: false,
|
||||||
|
// executablePath: "/usr/bin/chromium",
|
||||||
|
// attachOnly: false, // set true when tunneling a remote CDP to localhost
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -3,7 +3,12 @@ import { createServer } from "node:http";
|
|||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
import { WebSocketServer } from "ws";
|
import { WebSocketServer } from "ws";
|
||||||
import { rawDataToString } from "../infra/ws.js";
|
import { rawDataToString } from "../infra/ws.js";
|
||||||
import { createTargetViaCdp, evaluateJavaScript, snapshotAria } from "./cdp.js";
|
import {
|
||||||
|
createTargetViaCdp,
|
||||||
|
evaluateJavaScript,
|
||||||
|
normalizeCdpWsUrl,
|
||||||
|
snapshotAria,
|
||||||
|
} from "./cdp.js";
|
||||||
|
|
||||||
describe("cdp", () => {
|
describe("cdp", () => {
|
||||||
let httpServer: ReturnType<typeof createServer> | null = null;
|
let httpServer: ReturnType<typeof createServer> | null = null;
|
||||||
@@ -64,7 +69,7 @@ describe("cdp", () => {
|
|||||||
const httpPort = (httpServer.address() as { port: number }).port;
|
const httpPort = (httpServer.address() as { port: number }).port;
|
||||||
|
|
||||||
const created = await createTargetViaCdp({
|
const created = await createTargetViaCdp({
|
||||||
cdpPort: httpPort,
|
cdpUrl: `http://127.0.0.1:${httpPort}`,
|
||||||
url: "https://example.com",
|
url: "https://example.com",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -159,4 +164,12 @@ describe("cdp", () => {
|
|||||||
expect(snap.nodes[1]?.backendDOMNodeId).toBe(42);
|
expect(snap.nodes[1]?.backendDOMNodeId).toBe(42);
|
||||||
expect(snap.nodes[1]?.depth).toBe(1);
|
expect(snap.nodes[1]?.depth).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("normalizes loopback websocket URLs for remote CDP hosts", () => {
|
||||||
|
const normalized = normalizeCdpWsUrl(
|
||||||
|
"ws://127.0.0.1:9222/devtools/browser/ABC",
|
||||||
|
"http://example.com:9222",
|
||||||
|
);
|
||||||
|
expect(normalized).toBe("ws://example.com:9222/devtools/browser/ABC");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,6 +18,31 @@ type CdpSendFn = (
|
|||||||
params?: Record<string, unknown>,
|
params?: Record<string, unknown>,
|
||||||
) => Promise<unknown>;
|
) => Promise<unknown>;
|
||||||
|
|
||||||
|
function isLoopbackHost(host: string) {
|
||||||
|
const h = host.trim().toLowerCase();
|
||||||
|
return (
|
||||||
|
h === "localhost" ||
|
||||||
|
h === "127.0.0.1" ||
|
||||||
|
h === "0.0.0.0" ||
|
||||||
|
h === "[::1]" ||
|
||||||
|
h === "::1" ||
|
||||||
|
h === "[::]" ||
|
||||||
|
h === "::"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeCdpWsUrl(wsUrl: string, cdpUrl: string): string {
|
||||||
|
const ws = new URL(wsUrl);
|
||||||
|
const cdp = new URL(cdpUrl);
|
||||||
|
if (isLoopbackHost(ws.hostname) && !isLoopbackHost(cdp.hostname)) {
|
||||||
|
ws.hostname = cdp.hostname;
|
||||||
|
const cdpPort = cdp.port || (cdp.protocol === "https:" ? "443" : "80");
|
||||||
|
if (cdpPort) ws.port = cdpPort;
|
||||||
|
ws.protocol = cdp.protocol === "https:" ? "wss:" : "ws:";
|
||||||
|
}
|
||||||
|
return ws.toString();
|
||||||
|
}
|
||||||
|
|
||||||
function createCdpSender(ws: WebSocket) {
|
function createCdpSender(ws: WebSocket) {
|
||||||
let nextId = 1;
|
let nextId = 1;
|
||||||
const pending = new Map<number, Pending>();
|
const pending = new Map<number, Pending>();
|
||||||
@@ -165,14 +190,16 @@ export async function captureScreenshot(opts: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function createTargetViaCdp(opts: {
|
export async function createTargetViaCdp(opts: {
|
||||||
cdpPort: number;
|
cdpUrl: string;
|
||||||
url: string;
|
url: string;
|
||||||
}): Promise<{ targetId: string }> {
|
}): Promise<{ targetId: string }> {
|
||||||
|
const base = opts.cdpUrl.replace(/\/$/, "");
|
||||||
const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(
|
const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(
|
||||||
`http://127.0.0.1:${opts.cdpPort}/json/version`,
|
`${base}/json/version`,
|
||||||
1500,
|
1500,
|
||||||
);
|
);
|
||||||
const wsUrl = String(version?.webSocketDebuggerUrl ?? "").trim();
|
const wsUrlRaw = String(version?.webSocketDebuggerUrl ?? "").trim();
|
||||||
|
const wsUrl = wsUrlRaw ? normalizeCdpWsUrl(wsUrlRaw, opts.cdpUrl) : "";
|
||||||
if (!wsUrl) throw new Error("CDP /json/version missing webSocketDebuggerUrl");
|
if (!wsUrl) throw new Error("CDP /json/version missing webSocketDebuggerUrl");
|
||||||
|
|
||||||
return await withCdpSocket(wsUrl, async (send) => {
|
return await withCdpSocket(wsUrl, async (send) => {
|
||||||
|
|||||||
@@ -163,7 +163,9 @@ describe("browser chrome helpers", () => {
|
|||||||
json: async () => ({ webSocketDebuggerUrl: "ws://127.0.0.1/devtools" }),
|
json: async () => ({ webSocketDebuggerUrl: "ws://127.0.0.1/devtools" }),
|
||||||
} as unknown as Response),
|
} as unknown as Response),
|
||||||
);
|
);
|
||||||
await expect(isChromeReachable(12345, 50)).resolves.toBe(true);
|
await expect(isChromeReachable("http://127.0.0.1:12345", 50)).resolves.toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
"fetch",
|
"fetch",
|
||||||
@@ -172,10 +174,14 @@ describe("browser chrome helpers", () => {
|
|||||||
json: async () => ({}),
|
json: async () => ({}),
|
||||||
} as unknown as Response),
|
} as unknown as Response),
|
||||||
);
|
);
|
||||||
await expect(isChromeReachable(12345, 50)).resolves.toBe(false);
|
await expect(isChromeReachable("http://127.0.0.1:12345", 50)).resolves.toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("boom")));
|
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("boom")));
|
||||||
await expect(isChromeReachable(12345, 50)).resolves.toBe(false);
|
await expect(isChromeReachable("http://127.0.0.1:12345", 50)).resolves.toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("stopClawdChrome no-ops when process is already killed", async () => {
|
it("stopClawdChrome no-ops when process is already killed", async () => {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import WebSocket from "ws";
|
|||||||
import { ensurePortAvailable } from "../infra/ports.js";
|
import { ensurePortAvailable } from "../infra/ports.js";
|
||||||
import { createSubsystemLogger } from "../logging.js";
|
import { createSubsystemLogger } from "../logging.js";
|
||||||
import { CONFIG_DIR } from "../utils.js";
|
import { CONFIG_DIR } from "../utils.js";
|
||||||
|
import { normalizeCdpWsUrl } from "./cdp.js";
|
||||||
import type { ResolvedBrowserConfig } from "./config.js";
|
import type { ResolvedBrowserConfig } from "./config.js";
|
||||||
import {
|
import {
|
||||||
DEFAULT_CLAWD_BROWSER_COLOR,
|
DEFAULT_CLAWD_BROWSER_COLOR,
|
||||||
@@ -16,7 +17,7 @@ import {
|
|||||||
const log = createSubsystemLogger("browser").child("chrome");
|
const log = createSubsystemLogger("browser").child("chrome");
|
||||||
|
|
||||||
export type BrowserExecutable = {
|
export type BrowserExecutable = {
|
||||||
kind: "canary" | "chromium" | "chrome";
|
kind: "canary" | "chromium" | "chrome" | "custom";
|
||||||
path: string;
|
path: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -81,6 +82,40 @@ export function findChromeExecutableMac(): BrowserExecutable | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function findChromeExecutableLinux(): BrowserExecutable | null {
|
||||||
|
const candidates: Array<BrowserExecutable> = [
|
||||||
|
{ kind: "chrome", path: "/usr/bin/google-chrome" },
|
||||||
|
{ kind: "chrome", path: "/usr/bin/google-chrome-stable" },
|
||||||
|
{ kind: "chromium", path: "/usr/bin/chromium" },
|
||||||
|
{ kind: "chromium", path: "/usr/bin/chromium-browser" },
|
||||||
|
{ kind: "chromium", path: "/snap/bin/chromium" },
|
||||||
|
{ kind: "chrome", path: "/usr/bin/chrome" },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (exists(candidate.path)) return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveBrowserExecutable(
|
||||||
|
resolved: ResolvedBrowserConfig,
|
||||||
|
): BrowserExecutable | null {
|
||||||
|
if (resolved.executablePath) {
|
||||||
|
if (!exists(resolved.executablePath)) {
|
||||||
|
throw new Error(
|
||||||
|
`browser.executablePath not found: ${resolved.executablePath}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { kind: "custom", path: resolved.executablePath };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.platform === "darwin") return findChromeExecutableMac();
|
||||||
|
if (process.platform === "linux") return findChromeExecutableLinux();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveClawdUserDataDir() {
|
export function resolveClawdUserDataDir() {
|
||||||
return path.join(
|
return path.join(
|
||||||
CONFIG_DIR,
|
CONFIG_DIR,
|
||||||
@@ -112,6 +147,10 @@ function safeWriteJson(filePath: string, data: Record<string, unknown>) {
|
|||||||
fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
|
fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cdpUrlForPort(cdpPort: number) {
|
||||||
|
return `http://127.0.0.1:${cdpPort}`;
|
||||||
|
}
|
||||||
|
|
||||||
function setDeep(obj: Record<string, unknown>, keys: string[], value: unknown) {
|
function setDeep(obj: Record<string, unknown>, keys: string[], value: unknown) {
|
||||||
let node: Record<string, unknown> = obj;
|
let node: Record<string, unknown> = obj;
|
||||||
for (const key of keys.slice(0, -1)) {
|
for (const key of keys.slice(0, -1)) {
|
||||||
@@ -304,10 +343,10 @@ export function decorateClawdProfile(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function isChromeReachable(
|
export async function isChromeReachable(
|
||||||
cdpPort: number,
|
cdpUrl: string,
|
||||||
timeoutMs = 500,
|
timeoutMs = 500,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const version = await fetchChromeVersion(cdpPort, timeoutMs);
|
const version = await fetchChromeVersion(cdpUrl, timeoutMs);
|
||||||
return Boolean(version);
|
return Boolean(version);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -318,13 +357,14 @@ type ChromeVersion = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
async function fetchChromeVersion(
|
async function fetchChromeVersion(
|
||||||
cdpPort: number,
|
cdpUrl: string,
|
||||||
timeoutMs = 500,
|
timeoutMs = 500,
|
||||||
): Promise<ChromeVersion | null> {
|
): Promise<ChromeVersion | null> {
|
||||||
const ctrl = new AbortController();
|
const ctrl = new AbortController();
|
||||||
const t = setTimeout(() => ctrl.abort(), timeoutMs);
|
const t = setTimeout(() => ctrl.abort(), timeoutMs);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`http://127.0.0.1:${cdpPort}/json/version`, {
|
const base = cdpUrl.replace(/\/$/, "");
|
||||||
|
const res = await fetch(`${base}/json/version`, {
|
||||||
signal: ctrl.signal,
|
signal: ctrl.signal,
|
||||||
});
|
});
|
||||||
if (!res.ok) return null;
|
if (!res.ok) return null;
|
||||||
@@ -339,12 +379,13 @@ async function fetchChromeVersion(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getChromeWebSocketUrl(
|
export async function getChromeWebSocketUrl(
|
||||||
cdpPort: number,
|
cdpUrl: string,
|
||||||
timeoutMs = 500,
|
timeoutMs = 500,
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
const version = await fetchChromeVersion(cdpPort, timeoutMs);
|
const version = await fetchChromeVersion(cdpUrl, timeoutMs);
|
||||||
const wsUrl = String(version?.webSocketDebuggerUrl ?? "").trim();
|
const wsUrl = String(version?.webSocketDebuggerUrl ?? "").trim();
|
||||||
return wsUrl ? wsUrl : null;
|
if (!wsUrl) return null;
|
||||||
|
return normalizeCdpWsUrl(wsUrl, cdpUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function canOpenWebSocket(
|
async function canOpenWebSocket(
|
||||||
@@ -381,11 +422,11 @@ async function canOpenWebSocket(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function isChromeCdpReady(
|
export async function isChromeCdpReady(
|
||||||
cdpPort: number,
|
cdpUrl: string,
|
||||||
timeoutMs = 500,
|
timeoutMs = 500,
|
||||||
handshakeTimeoutMs = 800,
|
handshakeTimeoutMs = 800,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const wsUrl = await getChromeWebSocketUrl(cdpPort, timeoutMs);
|
const wsUrl = await getChromeWebSocketUrl(cdpUrl, timeoutMs);
|
||||||
if (!wsUrl) return false;
|
if (!wsUrl) return false;
|
||||||
return await canOpenWebSocket(wsUrl, handshakeTimeoutMs);
|
return await canOpenWebSocket(wsUrl, handshakeTimeoutMs);
|
||||||
}
|
}
|
||||||
@@ -395,10 +436,10 @@ export async function launchClawdChrome(
|
|||||||
): Promise<RunningChrome> {
|
): Promise<RunningChrome> {
|
||||||
await ensurePortAvailable(resolved.cdpPort);
|
await ensurePortAvailable(resolved.cdpPort);
|
||||||
|
|
||||||
const exe = process.platform === "darwin" ? findChromeExecutableMac() : null;
|
const exe = resolveBrowserExecutable(resolved);
|
||||||
if (!exe) {
|
if (!exe) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"No supported browser found (Chrome Canary/Chromium/Chrome on macOS).",
|
"No supported browser found (Chrome/Chromium on macOS or Linux).",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -430,6 +471,13 @@ export async function launchClawdChrome(
|
|||||||
args.push("--headless=new");
|
args.push("--headless=new");
|
||||||
args.push("--disable-gpu");
|
args.push("--disable-gpu");
|
||||||
}
|
}
|
||||||
|
if (resolved.noSandbox) {
|
||||||
|
args.push("--no-sandbox");
|
||||||
|
args.push("--disable-setuid-sandbox");
|
||||||
|
}
|
||||||
|
if (process.platform === "linux") {
|
||||||
|
args.push("--disable-dev-shm-usage");
|
||||||
|
}
|
||||||
|
|
||||||
// Always open a blank tab to ensure a target exists.
|
// Always open a blank tab to ensure a target exists.
|
||||||
args.push("about:blank");
|
args.push("about:blank");
|
||||||
@@ -484,11 +532,11 @@ export async function launchClawdChrome(
|
|||||||
// Wait for CDP to come up.
|
// Wait for CDP to come up.
|
||||||
const readyDeadline = Date.now() + 15_000;
|
const readyDeadline = Date.now() + 15_000;
|
||||||
while (Date.now() < readyDeadline) {
|
while (Date.now() < readyDeadline) {
|
||||||
if (await isChromeReachable(resolved.cdpPort, 500)) break;
|
if (await isChromeReachable(resolved.cdpUrl, 500)) break;
|
||||||
await new Promise((r) => setTimeout(r, 200));
|
await new Promise((r) => setTimeout(r, 200));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!(await isChromeReachable(resolved.cdpPort, 500))) {
|
if (!(await isChromeReachable(resolved.cdpUrl, 500))) {
|
||||||
try {
|
try {
|
||||||
proc.kill("SIGKILL");
|
proc.kill("SIGKILL");
|
||||||
} catch {
|
} catch {
|
||||||
@@ -527,7 +575,7 @@ export async function stopClawdChrome(
|
|||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
while (Date.now() - start < timeoutMs) {
|
while (Date.now() - start < timeoutMs) {
|
||||||
if (!proc.exitCode && proc.killed) break;
|
if (!proc.exitCode && proc.killed) break;
|
||||||
if (!(await isChromeReachable(running.cdpPort, 200))) return;
|
if (!(await isChromeReachable(cdpUrlForPort(running.cdpPort), 200))) return;
|
||||||
await new Promise((r) => setTimeout(r, 100));
|
await new Promise((r) => setTimeout(r, 100));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -169,10 +169,13 @@ describe("browser client", () => {
|
|||||||
running: true,
|
running: true,
|
||||||
pid: 1,
|
pid: 1,
|
||||||
cdpPort: 18792,
|
cdpPort: 18792,
|
||||||
|
cdpUrl: "http://127.0.0.1:18792",
|
||||||
chosenBrowser: "chrome",
|
chosenBrowser: "chrome",
|
||||||
userDataDir: "/tmp",
|
userDataDir: "/tmp",
|
||||||
color: "#FF4500",
|
color: "#FF4500",
|
||||||
headless: false,
|
headless: false,
|
||||||
|
noSandbox: false,
|
||||||
|
executablePath: null,
|
||||||
attachOnly: false,
|
attachOnly: false,
|
||||||
}),
|
}),
|
||||||
} as unknown as Response;
|
} as unknown as Response;
|
||||||
|
|||||||
@@ -10,10 +10,13 @@ export type BrowserStatus = {
|
|||||||
cdpHttp?: boolean;
|
cdpHttp?: boolean;
|
||||||
pid: number | null;
|
pid: number | null;
|
||||||
cdpPort: number;
|
cdpPort: number;
|
||||||
|
cdpUrl?: string;
|
||||||
chosenBrowser: string | null;
|
chosenBrowser: string | null;
|
||||||
userDataDir: string | null;
|
userDataDir: string | null;
|
||||||
color: string;
|
color: string;
|
||||||
headless: boolean;
|
headless: boolean;
|
||||||
|
noSandbox?: boolean;
|
||||||
|
executablePath?: string | null;
|
||||||
attachOnly: boolean;
|
attachOnly: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ describe("browser config", () => {
|
|||||||
expect(resolved.enabled).toBe(true);
|
expect(resolved.enabled).toBe(true);
|
||||||
expect(resolved.controlPort).toBe(18791);
|
expect(resolved.controlPort).toBe(18791);
|
||||||
expect(resolved.cdpPort).toBe(18792);
|
expect(resolved.cdpPort).toBe(18792);
|
||||||
|
expect(resolved.cdpUrl).toBe("http://127.0.0.1:18792");
|
||||||
expect(resolved.controlHost).toBe("127.0.0.1");
|
expect(resolved.controlHost).toBe("127.0.0.1");
|
||||||
expect(resolved.color).toBe("#FF4500");
|
expect(resolved.color).toBe("#FF4500");
|
||||||
expect(shouldStartLocalBrowserServer(resolved)).toBe(true);
|
expect(shouldStartLocalBrowserServer(resolved)).toBe(true);
|
||||||
@@ -44,6 +45,17 @@ describe("browser config", () => {
|
|||||||
});
|
});
|
||||||
expect(resolved.controlPort).toBe(19000);
|
expect(resolved.controlPort).toBe(19000);
|
||||||
expect(resolved.cdpPort).toBe(19001);
|
expect(resolved.cdpPort).toBe(19001);
|
||||||
|
expect(resolved.cdpUrl).toBe("http://127.0.0.1:19001");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("supports explicit CDP URLs", () => {
|
||||||
|
const resolved = resolveBrowserConfig({
|
||||||
|
controlUrl: "http://127.0.0.1:18791",
|
||||||
|
cdpUrl: "http://example.com:9222",
|
||||||
|
});
|
||||||
|
expect(resolved.cdpPort).toBe(9222);
|
||||||
|
expect(resolved.cdpUrl).toBe("http://example.com:9222");
|
||||||
|
expect(resolved.cdpIsLoopback).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects unsupported protocols", () => {
|
it("rejects unsupported protocols", () => {
|
||||||
|
|||||||
@@ -10,15 +10,28 @@ export type ResolvedBrowserConfig = {
|
|||||||
controlUrl: string;
|
controlUrl: string;
|
||||||
controlHost: string;
|
controlHost: string;
|
||||||
controlPort: number;
|
controlPort: number;
|
||||||
|
cdpUrl: string;
|
||||||
|
cdpHost: string;
|
||||||
cdpPort: number;
|
cdpPort: number;
|
||||||
|
cdpIsLoopback: boolean;
|
||||||
color: string;
|
color: string;
|
||||||
|
executablePath?: string;
|
||||||
headless: boolean;
|
headless: boolean;
|
||||||
|
noSandbox: boolean;
|
||||||
attachOnly: boolean;
|
attachOnly: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
function isLoopbackHost(host: string) {
|
function isLoopbackHost(host: string) {
|
||||||
const h = host.trim().toLowerCase();
|
const h = host.trim().toLowerCase();
|
||||||
return h === "localhost" || h === "127.0.0.1" || h === "[::1]" || h === "::1";
|
return (
|
||||||
|
h === "localhost" ||
|
||||||
|
h === "127.0.0.1" ||
|
||||||
|
h === "0.0.0.0" ||
|
||||||
|
h === "[::1]" ||
|
||||||
|
h === "::1" ||
|
||||||
|
h === "[::]" ||
|
||||||
|
h === "::"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeHexColor(raw: string | undefined) {
|
function normalizeHexColor(raw: string | undefined) {
|
||||||
@@ -29,17 +42,12 @@ function normalizeHexColor(raw: string | undefined) {
|
|||||||
return normalized.toUpperCase();
|
return normalized.toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveBrowserConfig(
|
function parseHttpUrl(raw: string, label: string) {
|
||||||
cfg: BrowserConfig | undefined,
|
const trimmed = raw.trim();
|
||||||
): ResolvedBrowserConfig {
|
const parsed = new URL(trimmed);
|
||||||
const enabled = cfg?.enabled ?? DEFAULT_CLAWD_BROWSER_ENABLED;
|
|
||||||
const controlUrl = (
|
|
||||||
cfg?.controlUrl ?? DEFAULT_CLAWD_BROWSER_CONTROL_URL
|
|
||||||
).trim();
|
|
||||||
const parsed = new URL(controlUrl);
|
|
||||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`browser.controlUrl must be http(s), got: ${parsed.protocol.replace(":", "")}`,
|
`${label} must be http(s), got: ${parsed.protocol.replace(":", "")}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,32 +59,71 @@ export function resolveBrowserConfig(
|
|||||||
: 80;
|
: 80;
|
||||||
|
|
||||||
if (Number.isNaN(port) || port <= 0 || port > 65535) {
|
if (Number.isNaN(port) || port <= 0 || port > 65535) {
|
||||||
throw new Error(`browser.controlUrl has invalid port: ${parsed.port}`);
|
throw new Error(`${label} has invalid port: ${parsed.port}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const cdpPort = port + 1;
|
return {
|
||||||
if (cdpPort > 65535) {
|
parsed,
|
||||||
throw new Error(
|
port,
|
||||||
`browser.controlUrl port (${port}) is too high; cannot derive CDP port (${cdpPort})`,
|
normalized: parsed.toString().replace(/\/$/, ""),
|
||||||
);
|
};
|
||||||
}
|
}
|
||||||
if (port === cdpPort) {
|
|
||||||
throw new Error(
|
export function resolveBrowserConfig(
|
||||||
`browser.controlUrl port (${port}) must not equal CDP port (${cdpPort})`,
|
cfg: BrowserConfig | undefined,
|
||||||
);
|
): ResolvedBrowserConfig {
|
||||||
|
const enabled = cfg?.enabled ?? DEFAULT_CLAWD_BROWSER_ENABLED;
|
||||||
|
const controlInfo = parseHttpUrl(
|
||||||
|
cfg?.controlUrl ?? DEFAULT_CLAWD_BROWSER_CONTROL_URL,
|
||||||
|
"browser.controlUrl",
|
||||||
|
);
|
||||||
|
const controlPort = controlInfo.port;
|
||||||
|
|
||||||
|
const rawCdpUrl = (cfg?.cdpUrl ?? "").trim();
|
||||||
|
let cdpInfo:
|
||||||
|
| {
|
||||||
|
parsed: URL;
|
||||||
|
port: number;
|
||||||
|
normalized: string;
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
if (rawCdpUrl) {
|
||||||
|
cdpInfo = parseHttpUrl(rawCdpUrl, "browser.cdpUrl");
|
||||||
|
} else {
|
||||||
|
const derivedPort = controlPort + 1;
|
||||||
|
if (derivedPort > 65535) {
|
||||||
|
throw new Error(
|
||||||
|
`browser.controlUrl port (${controlPort}) is too high; cannot derive CDP port (${derivedPort})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const derived = new URL(controlInfo.normalized);
|
||||||
|
derived.port = String(derivedPort);
|
||||||
|
cdpInfo = {
|
||||||
|
parsed: derived,
|
||||||
|
port: derivedPort,
|
||||||
|
normalized: derived.toString().replace(/\/$/, ""),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cdpPort = cdpInfo.port;
|
||||||
const headless = cfg?.headless === true;
|
const headless = cfg?.headless === true;
|
||||||
|
const noSandbox = cfg?.noSandbox === true;
|
||||||
const attachOnly = cfg?.attachOnly === true;
|
const attachOnly = cfg?.attachOnly === true;
|
||||||
|
const executablePath = cfg?.executablePath?.trim() || undefined;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
enabled,
|
enabled,
|
||||||
controlUrl: parsed.toString().replace(/\/$/, ""),
|
controlUrl: controlInfo.normalized,
|
||||||
controlHost: parsed.hostname,
|
controlHost: controlInfo.parsed.hostname,
|
||||||
controlPort: port,
|
controlPort,
|
||||||
|
cdpUrl: cdpInfo.normalized,
|
||||||
|
cdpHost: cdpInfo.parsed.hostname,
|
||||||
cdpPort,
|
cdpPort,
|
||||||
|
cdpIsLoopback: isLoopbackHost(cdpInfo.parsed.hostname),
|
||||||
color: normalizeHexColor(cfg?.color),
|
color: normalizeHexColor(cfg?.color),
|
||||||
|
executablePath,
|
||||||
headless,
|
headless,
|
||||||
|
noSandbox,
|
||||||
attachOnly,
|
attachOnly,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ describe("pw-ai", () => {
|
|||||||
|
|
||||||
const mod = await importModule();
|
const mod = await importModule();
|
||||||
const res = await mod.snapshotAiViaPlaywright({
|
const res = await mod.snapshotAiViaPlaywright({
|
||||||
cdpPort: 18792,
|
cdpUrl: "http://127.0.0.1:18792",
|
||||||
targetId: "T2",
|
targetId: "T2",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -102,7 +102,7 @@ describe("pw-ai", () => {
|
|||||||
|
|
||||||
const mod = await importModule();
|
const mod = await importModule();
|
||||||
await mod.clickViaPlaywright({
|
await mod.clickViaPlaywright({
|
||||||
cdpPort: 18792,
|
cdpUrl: "http://127.0.0.1:18792",
|
||||||
targetId: "T1",
|
targetId: "T1",
|
||||||
ref: "76",
|
ref: "76",
|
||||||
});
|
});
|
||||||
@@ -121,7 +121,10 @@ describe("pw-ai", () => {
|
|||||||
|
|
||||||
const mod = await importModule();
|
const mod = await importModule();
|
||||||
await expect(
|
await expect(
|
||||||
mod.snapshotAiViaPlaywright({ cdpPort: 18792, targetId: "T1" }),
|
mod.snapshotAiViaPlaywright({
|
||||||
|
cdpUrl: "http://127.0.0.1:18792",
|
||||||
|
targetId: "T1",
|
||||||
|
}),
|
||||||
).rejects.toThrow(/_snapshotForAI/i);
|
).rejects.toThrow(/_snapshotForAI/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -133,9 +136,12 @@ describe("pw-ai", () => {
|
|||||||
connect.mockResolvedValue(browser);
|
connect.mockResolvedValue(browser);
|
||||||
|
|
||||||
const mod = await importModule();
|
const mod = await importModule();
|
||||||
await mod.snapshotAiViaPlaywright({ cdpPort: 18792, targetId: "T1" });
|
await mod.snapshotAiViaPlaywright({
|
||||||
|
cdpUrl: "http://127.0.0.1:18792",
|
||||||
|
targetId: "T1",
|
||||||
|
});
|
||||||
await mod.clickViaPlaywright({
|
await mod.clickViaPlaywright({
|
||||||
cdpPort: 18792,
|
cdpUrl: "http://127.0.0.1:18792",
|
||||||
targetId: "T1",
|
targetId: "T1",
|
||||||
ref: "1",
|
ref: "1",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type {
|
|||||||
} from "playwright-core";
|
} from "playwright-core";
|
||||||
import { chromium } from "playwright-core";
|
import { chromium } from "playwright-core";
|
||||||
import { formatErrorMessage } from "../infra/errors.js";
|
import { formatErrorMessage } from "../infra/errors.js";
|
||||||
|
import { getChromeWebSocketUrl } from "./chrome.js";
|
||||||
|
|
||||||
export type BrowserConsoleMessage = {
|
export type BrowserConsoleMessage = {
|
||||||
type: string;
|
type: string;
|
||||||
@@ -31,7 +32,7 @@ type TargetInfoResponse = {
|
|||||||
|
|
||||||
type ConnectedBrowser = {
|
type ConnectedBrowser = {
|
||||||
browser: Browser;
|
browser: Browser;
|
||||||
endpoint: string;
|
cdpUrl: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type PageState = {
|
type PageState = {
|
||||||
@@ -49,8 +50,8 @@ const MAX_CONSOLE_MESSAGES = 500;
|
|||||||
let cached: ConnectedBrowser | null = null;
|
let cached: ConnectedBrowser | null = null;
|
||||||
let connecting: Promise<ConnectedBrowser> | null = null;
|
let connecting: Promise<ConnectedBrowser> | null = null;
|
||||||
|
|
||||||
function endpointForCdpPort(cdpPort: number) {
|
function normalizeCdpUrl(raw: string) {
|
||||||
return `http://127.0.0.1:${cdpPort}`;
|
return raw.replace(/\/$/, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ensurePageState(page: Page): PageState {
|
export function ensurePageState(page: Page): PageState {
|
||||||
@@ -97,8 +98,9 @@ function observeBrowser(browser: Browser) {
|
|||||||
for (const context of browser.contexts()) observeContext(context);
|
for (const context of browser.contexts()) observeContext(context);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function connectBrowser(endpoint: string): Promise<ConnectedBrowser> {
|
async function connectBrowser(cdpUrl: string): Promise<ConnectedBrowser> {
|
||||||
if (cached?.endpoint === endpoint) return cached;
|
const normalized = normalizeCdpUrl(cdpUrl);
|
||||||
|
if (cached?.cdpUrl === normalized) return cached;
|
||||||
if (connecting) return await connecting;
|
if (connecting) return await connecting;
|
||||||
|
|
||||||
const connectWithRetry = async (): Promise<ConnectedBrowser> => {
|
const connectWithRetry = async (): Promise<ConnectedBrowser> => {
|
||||||
@@ -106,8 +108,12 @@ async function connectBrowser(endpoint: string): Promise<ConnectedBrowser> {
|
|||||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||||
try {
|
try {
|
||||||
const timeout = 5000 + attempt * 2000;
|
const timeout = 5000 + attempt * 2000;
|
||||||
|
const wsUrl = await getChromeWebSocketUrl(normalized, timeout).catch(
|
||||||
|
() => null,
|
||||||
|
);
|
||||||
|
const endpoint = wsUrl ?? normalized;
|
||||||
const browser = await chromium.connectOverCDP(endpoint, { timeout });
|
const browser = await chromium.connectOverCDP(endpoint, { timeout });
|
||||||
const connected: ConnectedBrowser = { browser, endpoint };
|
const connected: ConnectedBrowser = { browser, cdpUrl: normalized };
|
||||||
cached = connected;
|
cached = connected;
|
||||||
observeBrowser(browser);
|
observeBrowser(browser);
|
||||||
browser.on("disconnected", () => {
|
browser.on("disconnected", () => {
|
||||||
@@ -168,11 +174,10 @@ async function findPageByTargetId(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getPageForTargetId(opts: {
|
export async function getPageForTargetId(opts: {
|
||||||
cdpPort: number;
|
cdpUrl: string;
|
||||||
targetId?: string;
|
targetId?: string;
|
||||||
}): Promise<Page> {
|
}): Promise<Page> {
|
||||||
const endpoint = endpointForCdpPort(opts.cdpPort);
|
const { browser } = await connectBrowser(opts.cdpUrl);
|
||||||
const { browser } = await connectBrowser(endpoint);
|
|
||||||
const pages = await getAllPages(browser);
|
const pages = await getAllPages(browser);
|
||||||
if (!pages.length)
|
if (!pages.length)
|
||||||
throw new Error("No pages available in the connected browser.");
|
throw new Error("No pages available in the connected browser.");
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ describe("pw-tools-core", () => {
|
|||||||
|
|
||||||
const mod = await importModule();
|
const mod = await importModule();
|
||||||
const res = await mod.takeScreenshotViaPlaywright({
|
const res = await mod.takeScreenshotViaPlaywright({
|
||||||
cdpPort: 18792,
|
cdpUrl: "http://127.0.0.1:18792",
|
||||||
targetId: "T1",
|
targetId: "T1",
|
||||||
element: "#main",
|
element: "#main",
|
||||||
type: "png",
|
type: "png",
|
||||||
@@ -65,7 +65,7 @@ describe("pw-tools-core", () => {
|
|||||||
|
|
||||||
const mod = await importModule();
|
const mod = await importModule();
|
||||||
const res = await mod.takeScreenshotViaPlaywright({
|
const res = await mod.takeScreenshotViaPlaywright({
|
||||||
cdpPort: 18792,
|
cdpUrl: "http://127.0.0.1:18792",
|
||||||
targetId: "T1",
|
targetId: "T1",
|
||||||
ref: "76",
|
ref: "76",
|
||||||
type: "jpeg",
|
type: "jpeg",
|
||||||
@@ -89,7 +89,7 @@ describe("pw-tools-core", () => {
|
|||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
mod.takeScreenshotViaPlaywright({
|
mod.takeScreenshotViaPlaywright({
|
||||||
cdpPort: 18792,
|
cdpUrl: "http://127.0.0.1:18792",
|
||||||
targetId: "T1",
|
targetId: "T1",
|
||||||
element: "#x",
|
element: "#x",
|
||||||
fullPage: true,
|
fullPage: true,
|
||||||
@@ -98,7 +98,7 @@ describe("pw-tools-core", () => {
|
|||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
mod.takeScreenshotViaPlaywright({
|
mod.takeScreenshotViaPlaywright({
|
||||||
cdpPort: 18792,
|
cdpUrl: "http://127.0.0.1:18792",
|
||||||
targetId: "T1",
|
targetId: "T1",
|
||||||
ref: "1",
|
ref: "1",
|
||||||
fullPage: true,
|
fullPage: true,
|
||||||
@@ -118,7 +118,7 @@ describe("pw-tools-core", () => {
|
|||||||
|
|
||||||
const mod = await importModule();
|
const mod = await importModule();
|
||||||
await mod.armFileUploadViaPlaywright({
|
await mod.armFileUploadViaPlaywright({
|
||||||
cdpPort: 18792,
|
cdpUrl: "http://127.0.0.1:18792",
|
||||||
targetId: "T1",
|
targetId: "T1",
|
||||||
paths: ["/tmp/a.txt"],
|
paths: ["/tmp/a.txt"],
|
||||||
});
|
});
|
||||||
@@ -142,7 +142,10 @@ describe("pw-tools-core", () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const mod = await importModule();
|
const mod = await importModule();
|
||||||
await mod.armFileUploadViaPlaywright({ cdpPort: 18792, paths: [] });
|
await mod.armFileUploadViaPlaywright({
|
||||||
|
cdpUrl: "http://127.0.0.1:18792",
|
||||||
|
paths: [],
|
||||||
|
});
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
|
|
||||||
expect(fileChooser.setFiles).not.toHaveBeenCalled();
|
expect(fileChooser.setFiles).not.toHaveBeenCalled();
|
||||||
@@ -177,8 +180,14 @@ describe("pw-tools-core", () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const mod = await importModule();
|
const mod = await importModule();
|
||||||
await mod.armFileUploadViaPlaywright({ cdpPort: 18792, paths: ["/tmp/1"] });
|
await mod.armFileUploadViaPlaywright({
|
||||||
await mod.armFileUploadViaPlaywright({ cdpPort: 18792, paths: ["/tmp/2"] });
|
cdpUrl: "http://127.0.0.1:18792",
|
||||||
|
paths: ["/tmp/1"],
|
||||||
|
});
|
||||||
|
await mod.armFileUploadViaPlaywright({
|
||||||
|
cdpUrl: "http://127.0.0.1:18792",
|
||||||
|
paths: ["/tmp/2"],
|
||||||
|
});
|
||||||
|
|
||||||
resolve1?.(fc1);
|
resolve1?.(fc1);
|
||||||
resolve2?.(fc2);
|
resolve2?.(fc2);
|
||||||
@@ -199,7 +208,7 @@ describe("pw-tools-core", () => {
|
|||||||
|
|
||||||
const mod = await importModule();
|
const mod = await importModule();
|
||||||
await mod.armDialogViaPlaywright({
|
await mod.armDialogViaPlaywright({
|
||||||
cdpPort: 18792,
|
cdpUrl: "http://127.0.0.1:18792",
|
||||||
accept: true,
|
accept: true,
|
||||||
promptText: "x",
|
promptText: "x",
|
||||||
});
|
});
|
||||||
@@ -214,7 +223,7 @@ describe("pw-tools-core", () => {
|
|||||||
waitForEvent.mockClear();
|
waitForEvent.mockClear();
|
||||||
|
|
||||||
await mod.armDialogViaPlaywright({
|
await mod.armDialogViaPlaywright({
|
||||||
cdpPort: 18792,
|
cdpUrl: "http://127.0.0.1:18792",
|
||||||
accept: false,
|
accept: false,
|
||||||
});
|
});
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
|
|||||||
@@ -17,12 +17,12 @@ function requireRef(value: unknown): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function snapshotAiViaPlaywright(opts: {
|
export async function snapshotAiViaPlaywright(opts: {
|
||||||
cdpPort: number;
|
cdpUrl: string;
|
||||||
targetId?: string;
|
targetId?: string;
|
||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
}): Promise<{ snapshot: string }> {
|
}): Promise<{ snapshot: string }> {
|
||||||
const page = await getPageForTargetId({
|
const page = await getPageForTargetId({
|
||||||
cdpPort: opts.cdpPort,
|
cdpUrl: opts.cdpUrl,
|
||||||
targetId: opts.targetId,
|
targetId: opts.targetId,
|
||||||
});
|
});
|
||||||
ensurePageState(page);
|
ensurePageState(page);
|
||||||
@@ -45,7 +45,7 @@ export async function snapshotAiViaPlaywright(opts: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function clickViaPlaywright(opts: {
|
export async function clickViaPlaywright(opts: {
|
||||||
cdpPort: number;
|
cdpUrl: string;
|
||||||
targetId?: string;
|
targetId?: string;
|
||||||
ref: string;
|
ref: string;
|
||||||
doubleClick?: boolean;
|
doubleClick?: boolean;
|
||||||
@@ -54,7 +54,7 @@ export async function clickViaPlaywright(opts: {
|
|||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const page = await getPageForTargetId({
|
const page = await getPageForTargetId({
|
||||||
cdpPort: opts.cdpPort,
|
cdpUrl: opts.cdpUrl,
|
||||||
targetId: opts.targetId,
|
targetId: opts.targetId,
|
||||||
});
|
});
|
||||||
ensurePageState(page);
|
ensurePageState(page);
|
||||||
@@ -79,7 +79,7 @@ export async function clickViaPlaywright(opts: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function hoverViaPlaywright(opts: {
|
export async function hoverViaPlaywright(opts: {
|
||||||
cdpPort: number;
|
cdpUrl: string;
|
||||||
targetId?: string;
|
targetId?: string;
|
||||||
ref: string;
|
ref: string;
|
||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
@@ -94,7 +94,7 @@ export async function hoverViaPlaywright(opts: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function dragViaPlaywright(opts: {
|
export async function dragViaPlaywright(opts: {
|
||||||
cdpPort: number;
|
cdpUrl: string;
|
||||||
targetId?: string;
|
targetId?: string;
|
||||||
startRef: string;
|
startRef: string;
|
||||||
endRef: string;
|
endRef: string;
|
||||||
@@ -111,7 +111,7 @@ export async function dragViaPlaywright(opts: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function selectOptionViaPlaywright(opts: {
|
export async function selectOptionViaPlaywright(opts: {
|
||||||
cdpPort: number;
|
cdpUrl: string;
|
||||||
targetId?: string;
|
targetId?: string;
|
||||||
ref: string;
|
ref: string;
|
||||||
values: string[];
|
values: string[];
|
||||||
@@ -128,7 +128,7 @@ export async function selectOptionViaPlaywright(opts: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function pressKeyViaPlaywright(opts: {
|
export async function pressKeyViaPlaywright(opts: {
|
||||||
cdpPort: number;
|
cdpUrl: string;
|
||||||
targetId?: string;
|
targetId?: string;
|
||||||
key: string;
|
key: string;
|
||||||
delayMs?: number;
|
delayMs?: number;
|
||||||
@@ -143,7 +143,7 @@ export async function pressKeyViaPlaywright(opts: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function typeViaPlaywright(opts: {
|
export async function typeViaPlaywright(opts: {
|
||||||
cdpPort: number;
|
cdpUrl: string;
|
||||||
targetId?: string;
|
targetId?: string;
|
||||||
ref: string;
|
ref: string;
|
||||||
text: string;
|
text: string;
|
||||||
@@ -168,7 +168,7 @@ export async function typeViaPlaywright(opts: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function fillFormViaPlaywright(opts: {
|
export async function fillFormViaPlaywright(opts: {
|
||||||
cdpPort: number;
|
cdpUrl: string;
|
||||||
targetId?: string;
|
targetId?: string;
|
||||||
fields: BrowserFormField[];
|
fields: BrowserFormField[];
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
@@ -200,7 +200,7 @@ export async function fillFormViaPlaywright(opts: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function evaluateViaPlaywright(opts: {
|
export async function evaluateViaPlaywright(opts: {
|
||||||
cdpPort: number;
|
cdpUrl: string;
|
||||||
targetId?: string;
|
targetId?: string;
|
||||||
fn: string;
|
fn: string;
|
||||||
ref?: string;
|
ref?: string;
|
||||||
@@ -257,7 +257,7 @@ export async function evaluateViaPlaywright(opts: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function armFileUploadViaPlaywright(opts: {
|
export async function armFileUploadViaPlaywright(opts: {
|
||||||
cdpPort: number;
|
cdpUrl: string;
|
||||||
targetId?: string;
|
targetId?: string;
|
||||||
paths?: string[];
|
paths?: string[];
|
||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
@@ -304,7 +304,7 @@ export async function armFileUploadViaPlaywright(opts: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function setInputFilesViaPlaywright(opts: {
|
export async function setInputFilesViaPlaywright(opts: {
|
||||||
cdpPort: number;
|
cdpUrl: string;
|
||||||
targetId?: string;
|
targetId?: string;
|
||||||
inputRef?: string;
|
inputRef?: string;
|
||||||
element?: string;
|
element?: string;
|
||||||
@@ -342,7 +342,7 @@ export async function setInputFilesViaPlaywright(opts: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function armDialogViaPlaywright(opts: {
|
export async function armDialogViaPlaywright(opts: {
|
||||||
cdpPort: number;
|
cdpUrl: string;
|
||||||
targetId?: string;
|
targetId?: string;
|
||||||
accept: boolean;
|
accept: boolean;
|
||||||
promptText?: string;
|
promptText?: string;
|
||||||
@@ -368,7 +368,7 @@ export async function armDialogViaPlaywright(opts: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function navigateViaPlaywright(opts: {
|
export async function navigateViaPlaywright(opts: {
|
||||||
cdpPort: number;
|
cdpUrl: string;
|
||||||
targetId?: string;
|
targetId?: string;
|
||||||
url: string;
|
url: string;
|
||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
@@ -384,7 +384,7 @@ export async function navigateViaPlaywright(opts: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function waitForViaPlaywright(opts: {
|
export async function waitForViaPlaywright(opts: {
|
||||||
cdpPort: number;
|
cdpUrl: string;
|
||||||
targetId?: string;
|
targetId?: string;
|
||||||
timeMs?: number;
|
timeMs?: number;
|
||||||
text?: string;
|
text?: string;
|
||||||
@@ -417,7 +417,7 @@ export async function waitForViaPlaywright(opts: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function takeScreenshotViaPlaywright(opts: {
|
export async function takeScreenshotViaPlaywright(opts: {
|
||||||
cdpPort: number;
|
cdpUrl: string;
|
||||||
targetId?: string;
|
targetId?: string;
|
||||||
ref?: string;
|
ref?: string;
|
||||||
element?: string;
|
element?: string;
|
||||||
@@ -449,7 +449,7 @@ export async function takeScreenshotViaPlaywright(opts: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function resizeViewportViaPlaywright(opts: {
|
export async function resizeViewportViaPlaywright(opts: {
|
||||||
cdpPort: number;
|
cdpUrl: string;
|
||||||
targetId?: string;
|
targetId?: string;
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
@@ -463,7 +463,7 @@ export async function resizeViewportViaPlaywright(opts: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function closePageViaPlaywright(opts: {
|
export async function closePageViaPlaywright(opts: {
|
||||||
cdpPort: number;
|
cdpUrl: string;
|
||||||
targetId?: string;
|
targetId?: string;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const page = await getPageForTargetId(opts);
|
const page = await getPageForTargetId(opts);
|
||||||
@@ -472,7 +472,7 @@ export async function closePageViaPlaywright(opts: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function pdfViaPlaywright(opts: {
|
export async function pdfViaPlaywright(opts: {
|
||||||
cdpPort: number;
|
cdpUrl: string;
|
||||||
targetId?: string;
|
targetId?: string;
|
||||||
}): Promise<{ buffer: Buffer }> {
|
}): Promise<{ buffer: Buffer }> {
|
||||||
const page = await getPageForTargetId(opts);
|
const page = await getPageForTargetId(opts);
|
||||||
@@ -498,7 +498,7 @@ function consolePriority(level: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getConsoleMessagesViaPlaywright(opts: {
|
export async function getConsoleMessagesViaPlaywright(opts: {
|
||||||
cdpPort: number;
|
cdpUrl: string;
|
||||||
targetId?: string;
|
targetId?: string;
|
||||||
level?: string;
|
level?: string;
|
||||||
}): Promise<BrowserConsoleMessage[]> {
|
}): Promise<BrowserConsoleMessage[]> {
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ export function registerBrowserAgentRoutes(
|
|||||||
const pw = await requirePwAi(res, "navigate");
|
const pw = await requirePwAi(res, "navigate");
|
||||||
if (!pw) return;
|
if (!pw) return;
|
||||||
const result = await pw.navigateViaPlaywright({
|
const result = await pw.navigateViaPlaywright({
|
||||||
cdpPort: ctx.state().cdpPort,
|
cdpUrl: ctx.state().resolved.cdpUrl,
|
||||||
targetId: tab.targetId,
|
targetId: tab.targetId,
|
||||||
url,
|
url,
|
||||||
});
|
});
|
||||||
@@ -145,7 +145,7 @@ export function registerBrowserAgentRoutes(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const tab = await ctx.ensureTabAvailable(targetId);
|
const tab = await ctx.ensureTabAvailable(targetId);
|
||||||
const cdpPort = ctx.state().cdpPort;
|
const cdpUrl = ctx.state().resolved.cdpUrl;
|
||||||
const pw = await requirePwAi(res, `act:${kind}`);
|
const pw = await requirePwAi(res, `act:${kind}`);
|
||||||
if (!pw) return;
|
if (!pw) return;
|
||||||
|
|
||||||
@@ -180,7 +180,7 @@ export function registerBrowserAgentRoutes(
|
|||||||
? (modifiersRaw as ClickModifier[])
|
? (modifiersRaw as ClickModifier[])
|
||||||
: undefined;
|
: undefined;
|
||||||
const clickRequest: Parameters<typeof pw.clickViaPlaywright>[0] = {
|
const clickRequest: Parameters<typeof pw.clickViaPlaywright>[0] = {
|
||||||
cdpPort,
|
cdpUrl,
|
||||||
targetId: tab.targetId,
|
targetId: tab.targetId,
|
||||||
ref,
|
ref,
|
||||||
doubleClick,
|
doubleClick,
|
||||||
@@ -199,7 +199,7 @@ export function registerBrowserAgentRoutes(
|
|||||||
const submit = toBoolean(body.submit) ?? false;
|
const submit = toBoolean(body.submit) ?? false;
|
||||||
const slowly = toBoolean(body.slowly) ?? false;
|
const slowly = toBoolean(body.slowly) ?? false;
|
||||||
const typeRequest: Parameters<typeof pw.typeViaPlaywright>[0] = {
|
const typeRequest: Parameters<typeof pw.typeViaPlaywright>[0] = {
|
||||||
cdpPort,
|
cdpUrl,
|
||||||
targetId: tab.targetId,
|
targetId: tab.targetId,
|
||||||
ref,
|
ref,
|
||||||
text,
|
text,
|
||||||
@@ -213,7 +213,7 @@ export function registerBrowserAgentRoutes(
|
|||||||
const key = toStringOrEmpty(body.key);
|
const key = toStringOrEmpty(body.key);
|
||||||
if (!key) return jsonError(res, 400, "key is required");
|
if (!key) return jsonError(res, 400, "key is required");
|
||||||
await pw.pressKeyViaPlaywright({
|
await pw.pressKeyViaPlaywright({
|
||||||
cdpPort,
|
cdpUrl,
|
||||||
targetId: tab.targetId,
|
targetId: tab.targetId,
|
||||||
key,
|
key,
|
||||||
});
|
});
|
||||||
@@ -222,7 +222,7 @@ export function registerBrowserAgentRoutes(
|
|||||||
case "hover": {
|
case "hover": {
|
||||||
const ref = toStringOrEmpty(body.ref);
|
const ref = toStringOrEmpty(body.ref);
|
||||||
if (!ref) return jsonError(res, 400, "ref is required");
|
if (!ref) return jsonError(res, 400, "ref is required");
|
||||||
await pw.hoverViaPlaywright({ cdpPort, targetId: tab.targetId, ref });
|
await pw.hoverViaPlaywright({ cdpUrl, targetId: tab.targetId, ref });
|
||||||
return res.json({ ok: true, targetId: tab.targetId });
|
return res.json({ ok: true, targetId: tab.targetId });
|
||||||
}
|
}
|
||||||
case "drag": {
|
case "drag": {
|
||||||
@@ -231,7 +231,7 @@ export function registerBrowserAgentRoutes(
|
|||||||
if (!startRef || !endRef)
|
if (!startRef || !endRef)
|
||||||
return jsonError(res, 400, "startRef and endRef are required");
|
return jsonError(res, 400, "startRef and endRef are required");
|
||||||
await pw.dragViaPlaywright({
|
await pw.dragViaPlaywright({
|
||||||
cdpPort,
|
cdpUrl,
|
||||||
targetId: tab.targetId,
|
targetId: tab.targetId,
|
||||||
startRef,
|
startRef,
|
||||||
endRef,
|
endRef,
|
||||||
@@ -244,7 +244,7 @@ export function registerBrowserAgentRoutes(
|
|||||||
if (!ref || !values?.length)
|
if (!ref || !values?.length)
|
||||||
return jsonError(res, 400, "ref and values are required");
|
return jsonError(res, 400, "ref and values are required");
|
||||||
await pw.selectOptionViaPlaywright({
|
await pw.selectOptionViaPlaywright({
|
||||||
cdpPort,
|
cdpUrl,
|
||||||
targetId: tab.targetId,
|
targetId: tab.targetId,
|
||||||
ref,
|
ref,
|
||||||
values,
|
values,
|
||||||
@@ -273,7 +273,7 @@ export function registerBrowserAgentRoutes(
|
|||||||
.filter((field): field is BrowserFormField => field !== null);
|
.filter((field): field is BrowserFormField => field !== null);
|
||||||
if (!fields.length) return jsonError(res, 400, "fields are required");
|
if (!fields.length) return jsonError(res, 400, "fields are required");
|
||||||
await pw.fillFormViaPlaywright({
|
await pw.fillFormViaPlaywright({
|
||||||
cdpPort,
|
cdpUrl,
|
||||||
targetId: tab.targetId,
|
targetId: tab.targetId,
|
||||||
fields,
|
fields,
|
||||||
});
|
});
|
||||||
@@ -285,7 +285,7 @@ export function registerBrowserAgentRoutes(
|
|||||||
if (!width || !height)
|
if (!width || !height)
|
||||||
return jsonError(res, 400, "width and height are required");
|
return jsonError(res, 400, "width and height are required");
|
||||||
await pw.resizeViewportViaPlaywright({
|
await pw.resizeViewportViaPlaywright({
|
||||||
cdpPort,
|
cdpUrl,
|
||||||
targetId: tab.targetId,
|
targetId: tab.targetId,
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
@@ -297,7 +297,7 @@ export function registerBrowserAgentRoutes(
|
|||||||
const text = toStringOrEmpty(body.text) || undefined;
|
const text = toStringOrEmpty(body.text) || undefined;
|
||||||
const textGone = toStringOrEmpty(body.textGone) || undefined;
|
const textGone = toStringOrEmpty(body.textGone) || undefined;
|
||||||
await pw.waitForViaPlaywright({
|
await pw.waitForViaPlaywright({
|
||||||
cdpPort,
|
cdpUrl,
|
||||||
targetId: tab.targetId,
|
targetId: tab.targetId,
|
||||||
timeMs,
|
timeMs,
|
||||||
text,
|
text,
|
||||||
@@ -310,7 +310,7 @@ export function registerBrowserAgentRoutes(
|
|||||||
if (!fn) return jsonError(res, 400, "fn is required");
|
if (!fn) return jsonError(res, 400, "fn is required");
|
||||||
const ref = toStringOrEmpty(body.ref) || undefined;
|
const ref = toStringOrEmpty(body.ref) || undefined;
|
||||||
const result = await pw.evaluateViaPlaywright({
|
const result = await pw.evaluateViaPlaywright({
|
||||||
cdpPort,
|
cdpUrl,
|
||||||
targetId: tab.targetId,
|
targetId: tab.targetId,
|
||||||
fn,
|
fn,
|
||||||
ref,
|
ref,
|
||||||
@@ -323,7 +323,7 @@ export function registerBrowserAgentRoutes(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
case "close": {
|
case "close": {
|
||||||
await pw.closePageViaPlaywright({ cdpPort, targetId: tab.targetId });
|
await pw.closePageViaPlaywright({ cdpUrl, targetId: tab.targetId });
|
||||||
return res.json({ ok: true, targetId: tab.targetId });
|
return res.json({ ok: true, targetId: tab.targetId });
|
||||||
}
|
}
|
||||||
default: {
|
default: {
|
||||||
@@ -357,7 +357,7 @@ export function registerBrowserAgentRoutes(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
await pw.setInputFilesViaPlaywright({
|
await pw.setInputFilesViaPlaywright({
|
||||||
cdpPort: ctx.state().cdpPort,
|
cdpUrl: ctx.state().resolved.cdpUrl,
|
||||||
targetId: tab.targetId,
|
targetId: tab.targetId,
|
||||||
inputRef,
|
inputRef,
|
||||||
element,
|
element,
|
||||||
@@ -365,14 +365,14 @@ export function registerBrowserAgentRoutes(
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
await pw.armFileUploadViaPlaywright({
|
await pw.armFileUploadViaPlaywright({
|
||||||
cdpPort: ctx.state().cdpPort,
|
cdpUrl: ctx.state().resolved.cdpUrl,
|
||||||
targetId: tab.targetId,
|
targetId: tab.targetId,
|
||||||
paths,
|
paths,
|
||||||
timeoutMs: timeoutMs ?? undefined,
|
timeoutMs: timeoutMs ?? undefined,
|
||||||
});
|
});
|
||||||
if (ref) {
|
if (ref) {
|
||||||
await pw.clickViaPlaywright({
|
await pw.clickViaPlaywright({
|
||||||
cdpPort: ctx.state().cdpPort,
|
cdpUrl: ctx.state().resolved.cdpUrl,
|
||||||
targetId: tab.targetId,
|
targetId: tab.targetId,
|
||||||
ref,
|
ref,
|
||||||
});
|
});
|
||||||
@@ -396,7 +396,7 @@ export function registerBrowserAgentRoutes(
|
|||||||
const pw = await requirePwAi(res, "dialog hook");
|
const pw = await requirePwAi(res, "dialog hook");
|
||||||
if (!pw) return;
|
if (!pw) return;
|
||||||
await pw.armDialogViaPlaywright({
|
await pw.armDialogViaPlaywright({
|
||||||
cdpPort: ctx.state().cdpPort,
|
cdpUrl: ctx.state().resolved.cdpUrl,
|
||||||
targetId: tab.targetId,
|
targetId: tab.targetId,
|
||||||
accept,
|
accept,
|
||||||
promptText,
|
promptText,
|
||||||
@@ -418,7 +418,7 @@ export function registerBrowserAgentRoutes(
|
|||||||
const pw = await requirePwAi(res, "console messages");
|
const pw = await requirePwAi(res, "console messages");
|
||||||
if (!pw) return;
|
if (!pw) return;
|
||||||
const messages = await pw.getConsoleMessagesViaPlaywright({
|
const messages = await pw.getConsoleMessagesViaPlaywright({
|
||||||
cdpPort: ctx.state().cdpPort,
|
cdpUrl: ctx.state().resolved.cdpUrl,
|
||||||
targetId: tab.targetId,
|
targetId: tab.targetId,
|
||||||
level: level.trim() || undefined,
|
level: level.trim() || undefined,
|
||||||
});
|
});
|
||||||
@@ -436,7 +436,7 @@ export function registerBrowserAgentRoutes(
|
|||||||
const pw = await requirePwAi(res, "pdf");
|
const pw = await requirePwAi(res, "pdf");
|
||||||
if (!pw) return;
|
if (!pw) return;
|
||||||
const pdf = await pw.pdfViaPlaywright({
|
const pdf = await pw.pdfViaPlaywright({
|
||||||
cdpPort: ctx.state().cdpPort,
|
cdpUrl: ctx.state().resolved.cdpUrl,
|
||||||
targetId: tab.targetId,
|
targetId: tab.targetId,
|
||||||
});
|
});
|
||||||
await ensureMediaDir();
|
await ensureMediaDir();
|
||||||
@@ -480,7 +480,7 @@ export function registerBrowserAgentRoutes(
|
|||||||
const pw = await requirePwAi(res, "element/ref screenshot");
|
const pw = await requirePwAi(res, "element/ref screenshot");
|
||||||
if (!pw) return;
|
if (!pw) return;
|
||||||
const snap = await pw.takeScreenshotViaPlaywright({
|
const snap = await pw.takeScreenshotViaPlaywright({
|
||||||
cdpPort: ctx.state().cdpPort,
|
cdpUrl: ctx.state().resolved.cdpUrl,
|
||||||
targetId: tab.targetId,
|
targetId: tab.targetId,
|
||||||
ref,
|
ref,
|
||||||
element,
|
element,
|
||||||
@@ -539,7 +539,7 @@ export function registerBrowserAgentRoutes(
|
|||||||
const pw = await requirePwAi(res, "ai snapshot");
|
const pw = await requirePwAi(res, "ai snapshot");
|
||||||
if (!pw) return;
|
if (!pw) return;
|
||||||
const snap = await pw.snapshotAiViaPlaywright({
|
const snap = await pw.snapshotAiViaPlaywright({
|
||||||
cdpPort: ctx.state().cdpPort,
|
cdpUrl: ctx.state().resolved.cdpUrl,
|
||||||
targetId: tab.targetId,
|
targetId: tab.targetId,
|
||||||
});
|
});
|
||||||
return res.json({
|
return res.json({
|
||||||
|
|||||||
@@ -27,10 +27,13 @@ export function registerBrowserBasicRoutes(
|
|||||||
cdpHttp,
|
cdpHttp,
|
||||||
pid: current.running?.pid ?? null,
|
pid: current.running?.pid ?? null,
|
||||||
cdpPort: current.cdpPort,
|
cdpPort: current.cdpPort,
|
||||||
|
cdpUrl: current.resolved.cdpUrl,
|
||||||
chosenBrowser: current.running?.exe.kind ?? null,
|
chosenBrowser: current.running?.exe.kind ?? null,
|
||||||
userDataDir: current.running?.userDataDir ?? null,
|
userDataDir: current.running?.userDataDir ?? null,
|
||||||
color: current.resolved.color,
|
color: current.resolved.color,
|
||||||
headless: current.resolved.headless,
|
headless: current.resolved.headless,
|
||||||
|
noSandbox: current.resolved.noSandbox,
|
||||||
|
executablePath: current.resolved.executablePath ?? null,
|
||||||
attachOnly: current.resolved.attachOnly,
|
attachOnly: current.resolved.attachOnly,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import os from "node:os";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
import { runExec } from "../process/exec.js";
|
import { runExec } from "../process/exec.js";
|
||||||
import { createTargetViaCdp } from "./cdp.js";
|
import { createTargetViaCdp, normalizeCdpWsUrl } from "./cdp.js";
|
||||||
import {
|
import {
|
||||||
isChromeCdpReady,
|
isChromeCdpReady,
|
||||||
isChromeReachable,
|
isChromeReachable,
|
||||||
@@ -98,6 +98,15 @@ export function createBrowserRouteContext(
|
|||||||
|
|
||||||
const listTabs = async (): Promise<BrowserTab[]> => {
|
const listTabs = async (): Promise<BrowserTab[]> => {
|
||||||
const current = state();
|
const current = state();
|
||||||
|
const base = current.resolved.cdpUrl;
|
||||||
|
const normalizeWsUrl = (raw?: string) => {
|
||||||
|
if (!raw) return undefined;
|
||||||
|
try {
|
||||||
|
return normalizeCdpWsUrl(raw, base);
|
||||||
|
} catch {
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
};
|
||||||
const raw = await fetchJson<
|
const raw = await fetchJson<
|
||||||
Array<{
|
Array<{
|
||||||
id?: string;
|
id?: string;
|
||||||
@@ -106,13 +115,13 @@ export function createBrowserRouteContext(
|
|||||||
webSocketDebuggerUrl?: string;
|
webSocketDebuggerUrl?: string;
|
||||||
type?: string;
|
type?: string;
|
||||||
}>
|
}>
|
||||||
>(`http://127.0.0.1:${current.cdpPort}/json/list`);
|
>(`${base.replace(/\/$/, "")}/json/list`);
|
||||||
return raw
|
return raw
|
||||||
.map((t) => ({
|
.map((t) => ({
|
||||||
targetId: t.id ?? "",
|
targetId: t.id ?? "",
|
||||||
title: t.title ?? "",
|
title: t.title ?? "",
|
||||||
url: t.url ?? "",
|
url: t.url ?? "",
|
||||||
wsUrl: t.webSocketDebuggerUrl,
|
wsUrl: normalizeWsUrl(t.webSocketDebuggerUrl),
|
||||||
type: t.type,
|
type: t.type,
|
||||||
}))
|
}))
|
||||||
.filter((t) => Boolean(t.targetId));
|
.filter((t) => Boolean(t.targetId));
|
||||||
@@ -121,7 +130,7 @@ export function createBrowserRouteContext(
|
|||||||
const openTab = async (url: string): Promise<BrowserTab> => {
|
const openTab = async (url: string): Promise<BrowserTab> => {
|
||||||
const current = state();
|
const current = state();
|
||||||
const createdViaCdp = await createTargetViaCdp({
|
const createdViaCdp = await createTargetViaCdp({
|
||||||
cdpPort: current.cdpPort,
|
cdpUrl: current.resolved.cdpUrl,
|
||||||
url,
|
url,
|
||||||
})
|
})
|
||||||
.then((r) => r.targetId)
|
.then((r) => r.targetId)
|
||||||
@@ -148,7 +157,16 @@ export function createBrowserRouteContext(
|
|||||||
type?: string;
|
type?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const endpoint = `http://127.0.0.1:${current.cdpPort}/json/new?${encoded}`;
|
const base = current.resolved.cdpUrl.replace(/\/$/, "");
|
||||||
|
const normalizeWsUrl = (raw?: string) => {
|
||||||
|
if (!raw) return undefined;
|
||||||
|
try {
|
||||||
|
return normalizeCdpWsUrl(raw, base);
|
||||||
|
} catch {
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const endpoint = `${base}/json/new?${encoded}`;
|
||||||
const created = await fetchJson<CdpTarget>(endpoint, 1500, {
|
const created = await fetchJson<CdpTarget>(endpoint, 1500, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
}).catch(async (err) => {
|
}).catch(async (err) => {
|
||||||
@@ -163,7 +181,7 @@ export function createBrowserRouteContext(
|
|||||||
targetId: created.id,
|
targetId: created.id,
|
||||||
title: created.title ?? "",
|
title: created.title ?? "",
|
||||||
url: created.url ?? url,
|
url: created.url ?? url,
|
||||||
wsUrl: created.webSocketDebuggerUrl,
|
wsUrl: normalizeWsUrl(created.webSocketDebuggerUrl),
|
||||||
type: created.type,
|
type: created.type,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -171,12 +189,16 @@ export function createBrowserRouteContext(
|
|||||||
const isReachable = async (timeoutMs = 300) => {
|
const isReachable = async (timeoutMs = 300) => {
|
||||||
const current = state();
|
const current = state();
|
||||||
const wsTimeout = Math.max(200, Math.min(2000, timeoutMs * 2));
|
const wsTimeout = Math.max(200, Math.min(2000, timeoutMs * 2));
|
||||||
return await isChromeCdpReady(current.cdpPort, timeoutMs, wsTimeout);
|
return await isChromeCdpReady(
|
||||||
|
current.resolved.cdpUrl,
|
||||||
|
timeoutMs,
|
||||||
|
wsTimeout,
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const isHttpReachable = async (timeoutMs = 300) => {
|
const isHttpReachable = async (timeoutMs = 300) => {
|
||||||
const current = state();
|
const current = state();
|
||||||
return await isChromeReachable(current.cdpPort, timeoutMs);
|
return await isChromeReachable(current.resolved.cdpUrl, timeoutMs);
|
||||||
};
|
};
|
||||||
|
|
||||||
const attachRunning = (running: RunningChrome) => {
|
const attachRunning = (running: RunningChrome) => {
|
||||||
@@ -191,11 +213,14 @@ export function createBrowserRouteContext(
|
|||||||
|
|
||||||
const ensureBrowserAvailable = async (): Promise<void> => {
|
const ensureBrowserAvailable = async (): Promise<void> => {
|
||||||
const current = state();
|
const current = state();
|
||||||
|
const remoteCdp = !current.resolved.cdpIsLoopback;
|
||||||
const httpReachable = await isHttpReachable();
|
const httpReachable = await isHttpReachable();
|
||||||
if (!httpReachable) {
|
if (!httpReachable) {
|
||||||
if (current.resolved.attachOnly) {
|
if (current.resolved.attachOnly || remoteCdp) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Browser attachOnly is enabled and no browser is running.",
|
remoteCdp
|
||||||
|
? "Remote CDP is not reachable. Check browser.cdpUrl."
|
||||||
|
: "Browser attachOnly is enabled and no browser is running.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const launched = await launchClawdChrome(current.resolved);
|
const launched = await launchClawdChrome(current.resolved);
|
||||||
@@ -204,9 +229,11 @@ export function createBrowserRouteContext(
|
|||||||
|
|
||||||
if (await isReachable()) return;
|
if (await isReachable()) return;
|
||||||
|
|
||||||
if (current.resolved.attachOnly) {
|
if (current.resolved.attachOnly || remoteCdp) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Browser attachOnly is enabled and CDP websocket is not reachable.",
|
remoteCdp
|
||||||
|
? "Remote CDP websocket is not reachable. Check browser.cdpUrl."
|
||||||
|
: "Browser attachOnly is enabled and CDP websocket is not reachable.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,6 +282,7 @@ export function createBrowserRouteContext(
|
|||||||
|
|
||||||
const focusTab = async (targetId: string): Promise<void> => {
|
const focusTab = async (targetId: string): Promise<void> => {
|
||||||
const current = state();
|
const current = state();
|
||||||
|
const base = current.resolved.cdpUrl.replace(/\/$/, "");
|
||||||
const tabs = await listTabs();
|
const tabs = await listTabs();
|
||||||
const resolved = resolveTargetIdFromTabs(targetId, tabs);
|
const resolved = resolveTargetIdFromTabs(targetId, tabs);
|
||||||
if (!resolved.ok) {
|
if (!resolved.ok) {
|
||||||
@@ -263,13 +291,12 @@ export function createBrowserRouteContext(
|
|||||||
}
|
}
|
||||||
throw new Error("tab not found");
|
throw new Error("tab not found");
|
||||||
}
|
}
|
||||||
await fetchOk(
|
await fetchOk(`${base}/json/activate/${resolved.targetId}`);
|
||||||
`http://127.0.0.1:${current.cdpPort}/json/activate/${resolved.targetId}`,
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const closeTab = async (targetId: string): Promise<void> => {
|
const closeTab = async (targetId: string): Promise<void> => {
|
||||||
const current = state();
|
const current = state();
|
||||||
|
const base = current.resolved.cdpUrl.replace(/\/$/, "");
|
||||||
const tabs = await listTabs();
|
const tabs = await listTabs();
|
||||||
const resolved = resolveTargetIdFromTabs(targetId, tabs);
|
const resolved = resolveTargetIdFromTabs(targetId, tabs);
|
||||||
if (!resolved.ok) {
|
if (!resolved.ok) {
|
||||||
@@ -278,9 +305,7 @@ export function createBrowserRouteContext(
|
|||||||
}
|
}
|
||||||
throw new Error("tab not found");
|
throw new Error("tab not found");
|
||||||
}
|
}
|
||||||
await fetchOk(
|
await fetchOk(`${base}/json/close/${resolved.targetId}`);
|
||||||
`http://127.0.0.1:${current.cdpPort}/json/close/${resolved.targetId}`,
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const stopRunningBrowser = async (): Promise<{ stopped: boolean }> => {
|
const stopRunningBrowser = async (): Promise<{ stopped: boolean }> => {
|
||||||
@@ -293,6 +318,9 @@ export function createBrowserRouteContext(
|
|||||||
|
|
||||||
const resetProfile = async () => {
|
const resetProfile = async () => {
|
||||||
const current = state();
|
const current = state();
|
||||||
|
if (!current.resolved.cdpIsLoopback) {
|
||||||
|
throw new Error("reset-profile is only supported for local browsers.");
|
||||||
|
}
|
||||||
const userDataDir = resolveClawdUserDataDir();
|
const userDataDir = resolveClawdUserDataDir();
|
||||||
|
|
||||||
const httpReachable = await isHttpReachable(300);
|
const httpReachable = await isHttpReachable(300);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { fetch as realFetch } from "undici";
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
let testPort = 0;
|
let testPort = 0;
|
||||||
|
let cdpBaseUrl = "";
|
||||||
let reachable = false;
|
let reachable = false;
|
||||||
let cfgAttachOnly = false;
|
let cfgAttachOnly = false;
|
||||||
let createTargetId: string | null = null;
|
let createTargetId: string | null = null;
|
||||||
@@ -99,6 +100,7 @@ vi.mock("./chrome.js", () => ({
|
|||||||
|
|
||||||
vi.mock("./cdp.js", () => ({
|
vi.mock("./cdp.js", () => ({
|
||||||
createTargetViaCdp: cdpMocks.createTargetViaCdp,
|
createTargetViaCdp: cdpMocks.createTargetViaCdp,
|
||||||
|
normalizeCdpWsUrl: vi.fn((wsUrl: string) => wsUrl),
|
||||||
snapshotAria: cdpMocks.snapshotAria,
|
snapshotAria: cdpMocks.snapshotAria,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -159,6 +161,7 @@ describe("browser control server", () => {
|
|||||||
for (const fn of Object.values(cdpMocks)) fn.mockClear();
|
for (const fn of Object.values(cdpMocks)) fn.mockClear();
|
||||||
|
|
||||||
testPort = await getFreePort();
|
testPort = await getFreePort();
|
||||||
|
cdpBaseUrl = `http://127.0.0.1:${testPort + 1}`;
|
||||||
|
|
||||||
// Minimal CDP JSON endpoints used by the server.
|
// Minimal CDP JSON endpoints used by the server.
|
||||||
let putNewCalls = 0;
|
let putNewCalls = 0;
|
||||||
@@ -293,7 +296,7 @@ describe("browser control server", () => {
|
|||||||
expect(snapAi.ok).toBe(true);
|
expect(snapAi.ok).toBe(true);
|
||||||
expect(snapAi.format).toBe("ai");
|
expect(snapAi.format).toBe("ai");
|
||||||
expect(pwMocks.snapshotAiViaPlaywright).toHaveBeenCalledWith({
|
expect(pwMocks.snapshotAiViaPlaywright).toHaveBeenCalledWith({
|
||||||
cdpPort: testPort + 1,
|
cdpUrl: cdpBaseUrl,
|
||||||
targetId: "abcd1234",
|
targetId: "abcd1234",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -305,7 +308,7 @@ describe("browser control server", () => {
|
|||||||
expect(nav.ok).toBe(true);
|
expect(nav.ok).toBe(true);
|
||||||
expect(typeof nav.targetId).toBe("string");
|
expect(typeof nav.targetId).toBe("string");
|
||||||
expect(pwMocks.navigateViaPlaywright).toHaveBeenCalledWith({
|
expect(pwMocks.navigateViaPlaywright).toHaveBeenCalledWith({
|
||||||
cdpPort: testPort + 1,
|
cdpUrl: cdpBaseUrl,
|
||||||
targetId: "abcd1234",
|
targetId: "abcd1234",
|
||||||
url: "https://example.com",
|
url: "https://example.com",
|
||||||
});
|
});
|
||||||
@@ -322,7 +325,7 @@ describe("browser control server", () => {
|
|||||||
}).then((r) => r.json())) as { ok: boolean };
|
}).then((r) => r.json())) as { ok: boolean };
|
||||||
expect(click.ok).toBe(true);
|
expect(click.ok).toBe(true);
|
||||||
expect(pwMocks.clickViaPlaywright).toHaveBeenNthCalledWith(1, {
|
expect(pwMocks.clickViaPlaywright).toHaveBeenNthCalledWith(1, {
|
||||||
cdpPort: testPort + 1,
|
cdpUrl: cdpBaseUrl,
|
||||||
targetId: "abcd1234",
|
targetId: "abcd1234",
|
||||||
ref: "1",
|
ref: "1",
|
||||||
doubleClick: false,
|
doubleClick: false,
|
||||||
@@ -351,7 +354,7 @@ describe("browser control server", () => {
|
|||||||
}).then((r) => r.json())) as { ok: boolean };
|
}).then((r) => r.json())) as { ok: boolean };
|
||||||
expect(type.ok).toBe(true);
|
expect(type.ok).toBe(true);
|
||||||
expect(pwMocks.typeViaPlaywright).toHaveBeenNthCalledWith(1, {
|
expect(pwMocks.typeViaPlaywright).toHaveBeenNthCalledWith(1, {
|
||||||
cdpPort: testPort + 1,
|
cdpUrl: cdpBaseUrl,
|
||||||
targetId: "abcd1234",
|
targetId: "abcd1234",
|
||||||
ref: "1",
|
ref: "1",
|
||||||
text: "",
|
text: "",
|
||||||
@@ -366,7 +369,7 @@ describe("browser control server", () => {
|
|||||||
}).then((r) => r.json())) as { ok: boolean };
|
}).then((r) => r.json())) as { ok: boolean };
|
||||||
expect(press.ok).toBe(true);
|
expect(press.ok).toBe(true);
|
||||||
expect(pwMocks.pressKeyViaPlaywright).toHaveBeenCalledWith({
|
expect(pwMocks.pressKeyViaPlaywright).toHaveBeenCalledWith({
|
||||||
cdpPort: testPort + 1,
|
cdpUrl: cdpBaseUrl,
|
||||||
targetId: "abcd1234",
|
targetId: "abcd1234",
|
||||||
key: "Enter",
|
key: "Enter",
|
||||||
});
|
});
|
||||||
@@ -378,7 +381,7 @@ describe("browser control server", () => {
|
|||||||
}).then((r) => r.json())) as { ok: boolean };
|
}).then((r) => r.json())) as { ok: boolean };
|
||||||
expect(hover.ok).toBe(true);
|
expect(hover.ok).toBe(true);
|
||||||
expect(pwMocks.hoverViaPlaywright).toHaveBeenCalledWith({
|
expect(pwMocks.hoverViaPlaywright).toHaveBeenCalledWith({
|
||||||
cdpPort: testPort + 1,
|
cdpUrl: cdpBaseUrl,
|
||||||
targetId: "abcd1234",
|
targetId: "abcd1234",
|
||||||
ref: "2",
|
ref: "2",
|
||||||
});
|
});
|
||||||
@@ -390,7 +393,7 @@ describe("browser control server", () => {
|
|||||||
}).then((r) => r.json())) as { ok: boolean };
|
}).then((r) => r.json())) as { ok: boolean };
|
||||||
expect(drag.ok).toBe(true);
|
expect(drag.ok).toBe(true);
|
||||||
expect(pwMocks.dragViaPlaywright).toHaveBeenCalledWith({
|
expect(pwMocks.dragViaPlaywright).toHaveBeenCalledWith({
|
||||||
cdpPort: testPort + 1,
|
cdpUrl: cdpBaseUrl,
|
||||||
targetId: "abcd1234",
|
targetId: "abcd1234",
|
||||||
startRef: "3",
|
startRef: "3",
|
||||||
endRef: "4",
|
endRef: "4",
|
||||||
@@ -403,7 +406,7 @@ describe("browser control server", () => {
|
|||||||
}).then((r) => r.json())) as { ok: boolean };
|
}).then((r) => r.json())) as { ok: boolean };
|
||||||
expect(select.ok).toBe(true);
|
expect(select.ok).toBe(true);
|
||||||
expect(pwMocks.selectOptionViaPlaywright).toHaveBeenCalledWith({
|
expect(pwMocks.selectOptionViaPlaywright).toHaveBeenCalledWith({
|
||||||
cdpPort: testPort + 1,
|
cdpUrl: cdpBaseUrl,
|
||||||
targetId: "abcd1234",
|
targetId: "abcd1234",
|
||||||
ref: "5",
|
ref: "5",
|
||||||
values: ["a", "b"],
|
values: ["a", "b"],
|
||||||
@@ -419,7 +422,7 @@ describe("browser control server", () => {
|
|||||||
}).then((r) => r.json())) as { ok: boolean };
|
}).then((r) => r.json())) as { ok: boolean };
|
||||||
expect(fill.ok).toBe(true);
|
expect(fill.ok).toBe(true);
|
||||||
expect(pwMocks.fillFormViaPlaywright).toHaveBeenCalledWith({
|
expect(pwMocks.fillFormViaPlaywright).toHaveBeenCalledWith({
|
||||||
cdpPort: testPort + 1,
|
cdpUrl: cdpBaseUrl,
|
||||||
targetId: "abcd1234",
|
targetId: "abcd1234",
|
||||||
fields: [{ ref: "6", type: "textbox", value: "hello" }],
|
fields: [{ ref: "6", type: "textbox", value: "hello" }],
|
||||||
});
|
});
|
||||||
@@ -431,7 +434,7 @@ describe("browser control server", () => {
|
|||||||
}).then((r) => r.json())) as { ok: boolean };
|
}).then((r) => r.json())) as { ok: boolean };
|
||||||
expect(resize.ok).toBe(true);
|
expect(resize.ok).toBe(true);
|
||||||
expect(pwMocks.resizeViewportViaPlaywright).toHaveBeenCalledWith({
|
expect(pwMocks.resizeViewportViaPlaywright).toHaveBeenCalledWith({
|
||||||
cdpPort: testPort + 1,
|
cdpUrl: cdpBaseUrl,
|
||||||
targetId: "abcd1234",
|
targetId: "abcd1234",
|
||||||
width: 800,
|
width: 800,
|
||||||
height: 600,
|
height: 600,
|
||||||
@@ -444,7 +447,7 @@ describe("browser control server", () => {
|
|||||||
}).then((r) => r.json())) as { ok: boolean };
|
}).then((r) => r.json())) as { ok: boolean };
|
||||||
expect(wait.ok).toBe(true);
|
expect(wait.ok).toBe(true);
|
||||||
expect(pwMocks.waitForViaPlaywright).toHaveBeenCalledWith({
|
expect(pwMocks.waitForViaPlaywright).toHaveBeenCalledWith({
|
||||||
cdpPort: testPort + 1,
|
cdpUrl: cdpBaseUrl,
|
||||||
targetId: "abcd1234",
|
targetId: "abcd1234",
|
||||||
timeMs: 5,
|
timeMs: 5,
|
||||||
text: undefined,
|
text: undefined,
|
||||||
@@ -459,7 +462,7 @@ describe("browser control server", () => {
|
|||||||
expect(evalRes.ok).toBe(true);
|
expect(evalRes.ok).toBe(true);
|
||||||
expect(evalRes.result).toBe("ok");
|
expect(evalRes.result).toBe("ok");
|
||||||
expect(pwMocks.evaluateViaPlaywright).toHaveBeenCalledWith({
|
expect(pwMocks.evaluateViaPlaywright).toHaveBeenCalledWith({
|
||||||
cdpPort: testPort + 1,
|
cdpUrl: cdpBaseUrl,
|
||||||
targetId: "abcd1234",
|
targetId: "abcd1234",
|
||||||
fn: "() => 1",
|
fn: "() => 1",
|
||||||
ref: undefined,
|
ref: undefined,
|
||||||
@@ -472,7 +475,7 @@ describe("browser control server", () => {
|
|||||||
}).then((r) => r.json());
|
}).then((r) => r.json());
|
||||||
expect(upload).toMatchObject({ ok: true });
|
expect(upload).toMatchObject({ ok: true });
|
||||||
expect(pwMocks.armFileUploadViaPlaywright).toHaveBeenCalledWith({
|
expect(pwMocks.armFileUploadViaPlaywright).toHaveBeenCalledWith({
|
||||||
cdpPort: testPort + 1,
|
cdpUrl: cdpBaseUrl,
|
||||||
targetId: "abcd1234",
|
targetId: "abcd1234",
|
||||||
paths: ["/tmp/a.txt"],
|
paths: ["/tmp/a.txt"],
|
||||||
timeoutMs: 1234,
|
timeoutMs: 1234,
|
||||||
@@ -485,13 +488,13 @@ describe("browser control server", () => {
|
|||||||
}).then((r) => r.json());
|
}).then((r) => r.json());
|
||||||
expect(uploadWithRef).toMatchObject({ ok: true });
|
expect(uploadWithRef).toMatchObject({ ok: true });
|
||||||
expect(pwMocks.armFileUploadViaPlaywright).toHaveBeenCalledWith({
|
expect(pwMocks.armFileUploadViaPlaywright).toHaveBeenCalledWith({
|
||||||
cdpPort: testPort + 1,
|
cdpUrl: cdpBaseUrl,
|
||||||
targetId: "abcd1234",
|
targetId: "abcd1234",
|
||||||
paths: ["/tmp/b.txt"],
|
paths: ["/tmp/b.txt"],
|
||||||
timeoutMs: undefined,
|
timeoutMs: undefined,
|
||||||
});
|
});
|
||||||
expect(pwMocks.clickViaPlaywright).toHaveBeenCalledWith({
|
expect(pwMocks.clickViaPlaywright).toHaveBeenCalledWith({
|
||||||
cdpPort: testPort + 1,
|
cdpUrl: cdpBaseUrl,
|
||||||
targetId: "abcd1234",
|
targetId: "abcd1234",
|
||||||
ref: "e12",
|
ref: "e12",
|
||||||
});
|
});
|
||||||
@@ -503,7 +506,7 @@ describe("browser control server", () => {
|
|||||||
}).then((r) => r.json());
|
}).then((r) => r.json());
|
||||||
expect(uploadWithInputRef).toMatchObject({ ok: true });
|
expect(uploadWithInputRef).toMatchObject({ ok: true });
|
||||||
expect(pwMocks.setInputFilesViaPlaywright).toHaveBeenCalledWith({
|
expect(pwMocks.setInputFilesViaPlaywright).toHaveBeenCalledWith({
|
||||||
cdpPort: testPort + 1,
|
cdpUrl: cdpBaseUrl,
|
||||||
targetId: "abcd1234",
|
targetId: "abcd1234",
|
||||||
inputRef: "e99",
|
inputRef: "e99",
|
||||||
element: undefined,
|
element: undefined,
|
||||||
@@ -520,7 +523,7 @@ describe("browser control server", () => {
|
|||||||
}).then((r) => r.json());
|
}).then((r) => r.json());
|
||||||
expect(uploadWithElement).toMatchObject({ ok: true });
|
expect(uploadWithElement).toMatchObject({ ok: true });
|
||||||
expect(pwMocks.setInputFilesViaPlaywright).toHaveBeenCalledWith({
|
expect(pwMocks.setInputFilesViaPlaywright).toHaveBeenCalledWith({
|
||||||
cdpPort: testPort + 1,
|
cdpUrl: cdpBaseUrl,
|
||||||
targetId: "abcd1234",
|
targetId: "abcd1234",
|
||||||
inputRef: undefined,
|
inputRef: undefined,
|
||||||
element: "input[type=file]",
|
element: "input[type=file]",
|
||||||
@@ -534,7 +537,7 @@ describe("browser control server", () => {
|
|||||||
}).then((r) => r.json());
|
}).then((r) => r.json());
|
||||||
expect(dialog).toMatchObject({ ok: true });
|
expect(dialog).toMatchObject({ ok: true });
|
||||||
expect(pwMocks.armDialogViaPlaywright).toHaveBeenCalledWith({
|
expect(pwMocks.armDialogViaPlaywright).toHaveBeenCalledWith({
|
||||||
cdpPort: testPort + 1,
|
cdpUrl: cdpBaseUrl,
|
||||||
targetId: "abcd1234",
|
targetId: "abcd1234",
|
||||||
accept: true,
|
accept: true,
|
||||||
promptText: undefined,
|
promptText: undefined,
|
||||||
@@ -547,7 +550,7 @@ describe("browser control server", () => {
|
|||||||
expect(consoleRes.ok).toBe(true);
|
expect(consoleRes.ok).toBe(true);
|
||||||
expect(Array.isArray(consoleRes.messages)).toBe(true);
|
expect(Array.isArray(consoleRes.messages)).toBe(true);
|
||||||
expect(pwMocks.getConsoleMessagesViaPlaywright).toHaveBeenCalledWith({
|
expect(pwMocks.getConsoleMessagesViaPlaywright).toHaveBeenCalledWith({
|
||||||
cdpPort: testPort + 1,
|
cdpUrl: cdpBaseUrl,
|
||||||
targetId: "abcd1234",
|
targetId: "abcd1234",
|
||||||
level: "error",
|
level: "error",
|
||||||
});
|
});
|
||||||
@@ -568,7 +571,7 @@ describe("browser control server", () => {
|
|||||||
expect(shot.ok).toBe(true);
|
expect(shot.ok).toBe(true);
|
||||||
expect(typeof shot.path).toBe("string");
|
expect(typeof shot.path).toBe("string");
|
||||||
expect(pwMocks.takeScreenshotViaPlaywright).toHaveBeenCalledWith({
|
expect(pwMocks.takeScreenshotViaPlaywright).toHaveBeenCalledWith({
|
||||||
cdpPort: testPort + 1,
|
cdpUrl: cdpBaseUrl,
|
||||||
targetId: "abcd1234",
|
targetId: "abcd1234",
|
||||||
ref: undefined,
|
ref: undefined,
|
||||||
element: "body",
|
element: "body",
|
||||||
@@ -583,7 +586,7 @@ describe("browser control server", () => {
|
|||||||
}).then((r) => r.json())) as { ok: boolean };
|
}).then((r) => r.json())) as { ok: boolean };
|
||||||
expect(close.ok).toBe(true);
|
expect(close.ok).toBe(true);
|
||||||
expect(pwMocks.closePageViaPlaywright).toHaveBeenCalledWith({
|
expect(pwMocks.closePageViaPlaywright).toHaveBeenCalledWith({
|
||||||
cdpPort: testPort + 1,
|
cdpUrl: cdpBaseUrl,
|
||||||
targetId: "abcd1234",
|
targetId: "abcd1234",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export function registerBrowserManageCommands(
|
|||||||
`running: ${status.running}`,
|
`running: ${status.running}`,
|
||||||
`controlUrl: ${status.controlUrl}`,
|
`controlUrl: ${status.controlUrl}`,
|
||||||
`cdpPort: ${status.cdpPort}`,
|
`cdpPort: ${status.cdpPort}`,
|
||||||
|
`cdpUrl: ${status.cdpUrl ?? `http://127.0.0.1:${status.cdpPort}`}`,
|
||||||
`browser: ${status.chosenBrowser ?? "unknown"}`,
|
`browser: ${status.chosenBrowser ?? "unknown"}`,
|
||||||
`profileColor: ${status.color}`,
|
`profileColor: ${status.color}`,
|
||||||
].join("\n"),
|
].join("\n"),
|
||||||
|
|||||||
@@ -62,10 +62,16 @@ export type BrowserConfig = {
|
|||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
/** Base URL of the clawd browser control server. Default: http://127.0.0.1:18791 */
|
/** Base URL of the clawd browser control server. Default: http://127.0.0.1:18791 */
|
||||||
controlUrl?: string;
|
controlUrl?: string;
|
||||||
|
/** Base URL of the CDP endpoint. Default: controlUrl with port + 1. */
|
||||||
|
cdpUrl?: string;
|
||||||
/** Accent color for the clawd browser profile (hex). Default: #FF4500 */
|
/** Accent color for the clawd browser profile (hex). Default: #FF4500 */
|
||||||
color?: string;
|
color?: string;
|
||||||
|
/** Override the browser executable path (macOS/Linux). */
|
||||||
|
executablePath?: string;
|
||||||
/** Start Chrome headless (best-effort). Default: false */
|
/** Start Chrome headless (best-effort). Default: false */
|
||||||
headless?: boolean;
|
headless?: boolean;
|
||||||
|
/** Pass --no-sandbox to Chrome (Linux containers). Default: false */
|
||||||
|
noSandbox?: boolean;
|
||||||
/** If true: never launch; only attach to an existing browser. Default: false */
|
/** If true: never launch; only attach to an existing browser. Default: false */
|
||||||
attachOnly?: boolean;
|
attachOnly?: boolean;
|
||||||
};
|
};
|
||||||
@@ -759,8 +765,11 @@ const ClawdisSchema = z.object({
|
|||||||
.object({
|
.object({
|
||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
controlUrl: z.string().optional(),
|
controlUrl: z.string().optional(),
|
||||||
|
cdpUrl: z.string().optional(),
|
||||||
color: z.string().optional(),
|
color: z.string().optional(),
|
||||||
|
executablePath: z.string().optional(),
|
||||||
headless: z.boolean().optional(),
|
headless: z.boolean().optional(),
|
||||||
|
noSandbox: z.boolean().optional(),
|
||||||
attachOnly: z.boolean().optional(),
|
attachOnly: z.boolean().optional(),
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
|
|||||||
Reference in New Issue
Block a user