源码快照 @ 2026-09-02 (source-only, 剔除非源码素材)
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "@openwork/cdp",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"development": "./src/index.ts",
|
||||
"default": "./src/index.ts"
|
||||
},
|
||||
"./cdp": {
|
||||
"types": "./src/cdp.ts",
|
||||
"development": "./src/cdp.ts",
|
||||
"default": "./src/cdp.ts"
|
||||
},
|
||||
"./targets": {
|
||||
"types": "./src/targets.ts",
|
||||
"development": "./src/targets.ts",
|
||||
"default": "./src/targets.ts"
|
||||
},
|
||||
"./ports": {
|
||||
"types": "./src/ports.ts",
|
||||
"development": "./src/ports.ts",
|
||||
"default": "./src/ports.ts"
|
||||
},
|
||||
"./surface": {
|
||||
"types": "./src/surface.ts",
|
||||
"development": "./src/surface.ts",
|
||||
"default": "./src/surface.ts"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { evaluate } from "./cdp.ts";
|
||||
import type { CdpClient, EvaluateOptions } from "./cdp.ts";
|
||||
|
||||
/**
|
||||
* Readiness of the OpenWork desktop is defined by the UI being *interactive*,
|
||||
* not by the route matching an allowlist: a fresh profile with no workspace
|
||||
* legitimately sits on `/session` offering "Create or connect a workspace",
|
||||
* and a workspace can be selected while the panel still renders placeholders.
|
||||
*
|
||||
* This module is the single implementation of that predicate so the lifecycle
|
||||
* layer (@openwork/hosts) and the behaviours specs call (@openwork/behaviors)
|
||||
* cannot drift apart. It lives in @openwork/cdp because both already depend on
|
||||
* it, which keeps the dependency graph acyclic.
|
||||
*/
|
||||
|
||||
/** Copy the app renders while it is still settling (from apps/app/src/i18n/locales/en.ts). */
|
||||
export const APP_TRANSITIONAL_TEXTS = [
|
||||
"Preparing workspace",
|
||||
"Connecting signed-in services",
|
||||
"Connecting services",
|
||||
"Loading available resources",
|
||||
"Loading tasks",
|
||||
"Pulling in the latest messages",
|
||||
] as const;
|
||||
|
||||
export type AppSurfaceState = "welcome" | "workspace" | "no-workspace";
|
||||
|
||||
export interface AppStateProbe {
|
||||
/** The automation control API is registered. */
|
||||
controlReady: boolean;
|
||||
/** The transitional message currently on screen, if any. */
|
||||
transitional: string | null;
|
||||
/** Which interactive surface is showing, or null while nothing is usable yet. */
|
||||
surface: AppSurfaceState | null;
|
||||
workspaceId: string | null;
|
||||
route: string;
|
||||
/** First 300 characters of visible text, for self-diagnosing failures. */
|
||||
text: string;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function stringOrNull(value: unknown): string | null {
|
||||
return typeof value === "string" && value.length > 0 ? value : null;
|
||||
}
|
||||
|
||||
function surfaceOrNull(value: unknown): AppSurfaceState | null {
|
||||
return value === "welcome" || value === "workspace" || value === "no-workspace" ? value : null;
|
||||
}
|
||||
|
||||
export function parseAppStateProbe(value: unknown): AppStateProbe {
|
||||
if (!isRecord(value)) {
|
||||
return { controlReady: false, transitional: null, surface: null, workspaceId: null, route: "", text: "" };
|
||||
}
|
||||
return {
|
||||
controlReady: value.controlReady === true,
|
||||
transitional: stringOrNull(value.transitional),
|
||||
surface: surfaceOrNull(value.surface),
|
||||
workspaceId: stringOrNull(value.workspaceId),
|
||||
route: typeof value.route === "string" ? value.route : "",
|
||||
text: typeof value.text === "string" ? value.text : "",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The app is usable: control registered and a surface a user can act on.
|
||||
*
|
||||
* Transitional copy only disqualifies the workspace surfaces. The welcome screen
|
||||
* legitimately shows progress ("Preparing workspace") for a background step
|
||||
* while its own buttons remain perfectly clickable, and treating that as
|
||||
* not-ready waits forever for something that is not blocking the user.
|
||||
*/
|
||||
export function isInteractive(probe: AppStateProbe): boolean {
|
||||
if (!probe.controlReady || probe.surface === null) return false;
|
||||
if (probe.surface === "welcome") return true;
|
||||
return probe.transitional === null;
|
||||
}
|
||||
|
||||
export function describeAppState(probe: AppStateProbe): string {
|
||||
const blocker = probe.transitional ? `still showing ${JSON.stringify(probe.transitional)}` : "no interactive surface";
|
||||
return `route ${probe.route || "<unknown>"}, ${probe.controlReady ? "control ready" : "control missing"}, ${blocker}. Visible text: ${JSON.stringify(probe.text)}`;
|
||||
}
|
||||
|
||||
const PROBE_EXPRESSION = `(() => {
|
||||
const text = document.body?.innerText ?? "";
|
||||
const route = window.location.hash.replace(/^#/, "") || window.location.pathname;
|
||||
const transitional = ${JSON.stringify([...APP_TRANSITIONAL_TEXTS])}
|
||||
.find((message) => text.includes(message)) ?? null;
|
||||
const buttonLabels = [...document.querySelectorAll("button")].map((button) => (button.textContent ?? "").trim());
|
||||
const taskUi = text.includes("What do you need done?") || buttonLabels.includes("Run task");
|
||||
const needsWorkspace = text.includes("Create or connect a workspace");
|
||||
const welcome = text.includes("Welcome to OpenWork");
|
||||
// The product's own active-workspace state; the route is only a fallback
|
||||
// because a selected workspace does not always appear in the hash.
|
||||
const stored = localStorage.getItem("openwork.react.activeWorkspace");
|
||||
const routeMatch = (route.match(/\\/workspace\\/([^/?#]+)/) ?? [])[1] ?? null;
|
||||
const workspaceId = (stored && stored.length > 0 ? stored : null) ?? routeMatch;
|
||||
// Any settings/extensions surface inside a workspace is interactive too.
|
||||
const settingsSurface = /\\/workspace\\/[^/?#]+\\/(settings|extensions)/.test(route)
|
||||
&& (text.includes("Extensions") || text.includes("Preferences") || text.includes("Permissions"));
|
||||
const surface = welcome
|
||||
? "welcome"
|
||||
: (taskUi || settingsSurface) && workspaceId && !needsWorkspace
|
||||
? "workspace"
|
||||
: taskUi || needsWorkspace
|
||||
? "no-workspace"
|
||||
: null;
|
||||
return {
|
||||
controlReady: Boolean(window.__openworkControl),
|
||||
transitional,
|
||||
surface,
|
||||
// Report the id whenever the app knows one, even while the welcome surface
|
||||
// is showing: onboarding selects a workspace before it leaves that screen.
|
||||
workspaceId,
|
||||
route,
|
||||
text: text.slice(0, 300),
|
||||
};
|
||||
})()`;
|
||||
|
||||
export async function probeAppState(client: CdpClient, opts: EvaluateOptions = {}): Promise<AppStateProbe> {
|
||||
return parseAppStateProbe(await evaluate(client, PROBE_EXPRESSION, opts));
|
||||
}
|
||||
|
||||
/** The product's active workspace id, read from its own state. */
|
||||
export async function readActiveWorkspaceId(client: CdpClient, opts: EvaluateOptions = {}): Promise<string | null> {
|
||||
return (await probeAppState(client, opts)).workspaceId;
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
/**
|
||||
* Minimal Chrome DevTools Protocol client for the eval runner.
|
||||
*
|
||||
* Zero dependencies: uses the global fetch + WebSocket available in Node 24+.
|
||||
* Mirrors the pattern proven in apps/app/scripts/voice-cdp.mjs.
|
||||
*/
|
||||
|
||||
export interface CdpTarget {
|
||||
id: string;
|
||||
type: string;
|
||||
title: string;
|
||||
url: string;
|
||||
webSocketDebuggerUrl?: string;
|
||||
}
|
||||
|
||||
export interface CdpClient {
|
||||
targetId?: string | null;
|
||||
webSocketDebuggerUrl?: string;
|
||||
send(method: string, params?: Record<string, unknown>, options?: CdpSendOptions): Promise<unknown>;
|
||||
abort?(reason?: Error): void;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
export interface CdpSendOptions {
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
export interface CdpConnectOptions {
|
||||
connectTimeoutMs?: number;
|
||||
sendTimeoutMs?: number;
|
||||
}
|
||||
|
||||
export interface EvaluateOptions {
|
||||
awaitPromise?: boolean;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
export type CdpFunctionArgument = string | number | boolean | null;
|
||||
|
||||
/** Cheap DOM/CDP probes should fail quickly enough for their caller to retry. */
|
||||
export const DEFAULT_CDP_PROBE_TIMEOUT_MS = 8_000;
|
||||
|
||||
interface PendingCallbacks {
|
||||
resolve(value: unknown): void;
|
||||
reject(error: Error): void;
|
||||
timer: NodeJS.Timeout | null;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function stringField(value: unknown): string {
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
function optionalStringField(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
function parseTargets(payload: unknown): CdpTarget[] {
|
||||
if (!Array.isArray(payload)) {
|
||||
throw new Error("CDP target list response was not an array.");
|
||||
}
|
||||
return payload.map((entry, index) => {
|
||||
if (!isRecord(entry)) {
|
||||
return { id: String(index), type: "", title: "", url: "" };
|
||||
}
|
||||
return {
|
||||
id: stringField(entry.id) || String(index),
|
||||
type: stringField(entry.type),
|
||||
title: stringField(entry.title),
|
||||
url: stringField(entry.url),
|
||||
webSocketDebuggerUrl: optionalStringField(entry.webSocketDebuggerUrl),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function messageText(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
export async function listTargets(
|
||||
baseUrl: string,
|
||||
{ timeoutMs = DEFAULT_CDP_PROBE_TIMEOUT_MS }: { timeoutMs?: number } = {},
|
||||
): Promise<CdpTarget[]> {
|
||||
const response = await fetch(`${baseUrl.replace(/\/$/, "")}/json/list`, {
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Could not list CDP targets at ${baseUrl}: ${response.status}`);
|
||||
}
|
||||
return parseTargets(await response.json());
|
||||
}
|
||||
|
||||
export async function pickAppTarget(
|
||||
baseUrl: string,
|
||||
{ timeoutMs = DEFAULT_CDP_PROBE_TIMEOUT_MS }: { timeoutMs?: number } = {},
|
||||
): Promise<CdpTarget> {
|
||||
const targets = await listTargets(baseUrl, { timeoutMs });
|
||||
const pages = targets.filter((target) => target.type === "page" && target.webSocketDebuggerUrl);
|
||||
const target =
|
||||
pages.find((page) => page.title === "OpenWork") ??
|
||||
pages.find(
|
||||
(page) =>
|
||||
page.url.includes("localhost") ||
|
||||
page.url.includes("127.0.0.1") ||
|
||||
page.url.includes("[::1]"),
|
||||
) ??
|
||||
pages[0];
|
||||
if (!target) {
|
||||
throw new Error(`No CDP page target found at ${baseUrl}.`);
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chromium reports webSocketDebuggerUrl with its own local host
|
||||
* (e.g. ws://127.0.0.1:9825/devtools/page/<id>), which breaks when the
|
||||
* endpoint is reached through a proxy (e.g. Daytona preview URLs).
|
||||
* Rebuild the ws URL on the base URL's host and scheme.
|
||||
*/
|
||||
export function debuggerUrlFor(baseUrl: string, target: CdpTarget): string {
|
||||
if (!target.webSocketDebuggerUrl) {
|
||||
throw new Error(`CDP target ${target.id} has no webSocketDebuggerUrl.`);
|
||||
}
|
||||
const base = new URL(baseUrl);
|
||||
const ws = new URL(target.webSocketDebuggerUrl);
|
||||
ws.protocol = base.protocol === "https:" ? "wss:" : "ws:";
|
||||
ws.hostname = base.hostname;
|
||||
ws.port = base.port;
|
||||
return ws.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe a list of CDP base URL candidates and return the first that responds.
|
||||
*/
|
||||
export async function resolveCdpBaseUrl(candidates: string[]): Promise<string> {
|
||||
const errors: string[] = [];
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
await listTargets(candidate);
|
||||
return candidate;
|
||||
} catch (error) {
|
||||
errors.push(`${candidate}: ${messageText(error)}`);
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
`No CDP endpoint reachable. Tried:\n ${errors.join("\n ")}\n` +
|
||||
"Start the app first (pnpm dev) or pass --cdp-url.",
|
||||
);
|
||||
}
|
||||
|
||||
// Default upper bound on a single CDP round trip (handshake or a send/reply).
|
||||
// Without this, a stalled Daytona proxy WebSocket (seen intermittently — the
|
||||
// handshake or a single message reply just never arrives) hangs the calling
|
||||
// promise forever with no error, which hangs the whole flow silently.
|
||||
const DEFAULT_CDP_TIMEOUT_MS = 20_000;
|
||||
|
||||
export function connect(
|
||||
webSocketDebuggerUrl: string,
|
||||
{ connectTimeoutMs = DEFAULT_CDP_TIMEOUT_MS, sendTimeoutMs = DEFAULT_CDP_TIMEOUT_MS }: CdpConnectOptions = {},
|
||||
): Promise<CdpClient> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = new WebSocket(webSocketDebuggerUrl);
|
||||
let nextId = 1;
|
||||
const pending = new Map<number, PendingCallbacks>();
|
||||
let opened = false;
|
||||
let settled = false;
|
||||
let closed = false;
|
||||
|
||||
const connectTimer = setTimeout(() => {
|
||||
if (opened) return;
|
||||
settled = true;
|
||||
try {
|
||||
socket.close();
|
||||
} catch {
|
||||
// Socket may already be in a closing state.
|
||||
}
|
||||
reject(new Error(`CDP connect timed out after ${connectTimeoutMs}ms: ${webSocketDebuggerUrl}`));
|
||||
}, connectTimeoutMs);
|
||||
|
||||
const rejectPending = (error: Error) => {
|
||||
for (const callbacks of pending.values()) {
|
||||
if (callbacks.timer) clearTimeout(callbacks.timer);
|
||||
callbacks.reject(error);
|
||||
}
|
||||
pending.clear();
|
||||
};
|
||||
|
||||
const closeSocket = () => {
|
||||
closed = true;
|
||||
try {
|
||||
socket.close();
|
||||
} catch {
|
||||
// Socket may already be in a closing state.
|
||||
}
|
||||
};
|
||||
|
||||
socket.addEventListener("open", () => {
|
||||
if (settled) return;
|
||||
opened = true;
|
||||
if (connectTimer) clearTimeout(connectTimer);
|
||||
resolve({
|
||||
targetId: new URL(webSocketDebuggerUrl).pathname.split("/").pop() ?? null,
|
||||
webSocketDebuggerUrl,
|
||||
abort: (reason?: Error) => {
|
||||
const detail = reason ? `: ${reason.message}` : ".";
|
||||
rejectPending(new Error(`CDP transport stalled${detail}`, { cause: reason }));
|
||||
closeSocket();
|
||||
},
|
||||
close: closeSocket,
|
||||
send(method: string, params: Record<string, unknown> = {}, { timeoutMs = sendTimeoutMs }: CdpSendOptions = {}) {
|
||||
if (closed || socket.readyState !== WebSocket.OPEN) {
|
||||
return Promise.reject(new Error("CDP socket is not open."));
|
||||
}
|
||||
const id = nextId;
|
||||
nextId += 1;
|
||||
return new Promise((innerResolve, innerReject) => {
|
||||
// Name WHAT timed out. The rejection fires from a timer, so the
|
||||
// caller's stack is gone by then: "CDP call Runtime.evaluate timed
|
||||
// out" alone cannot tell you which of a spec's dozens of
|
||||
// evaluations blocked, which turns a one-line fix into a hunt.
|
||||
const subject = typeof params.expression === "string"
|
||||
? ` evaluating: ${params.expression.replace(/\s+/g, " ").trim().slice(0, 160)}`
|
||||
: "";
|
||||
const timer = setTimeout(() => {
|
||||
pending.delete(id);
|
||||
innerReject(new Error(`CDP call ${method} timed out after ${timeoutMs}ms.${subject}`));
|
||||
}, timeoutMs);
|
||||
pending.set(id, { resolve: innerResolve, reject: innerReject, timer });
|
||||
try {
|
||||
socket.send(JSON.stringify({ id, method, params }));
|
||||
} catch (error) {
|
||||
pending.delete(id);
|
||||
if (timer) clearTimeout(timer);
|
||||
innerReject(error);
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
});
|
||||
socket.addEventListener("message", (event) => {
|
||||
const parsed: unknown = JSON.parse(String(event.data));
|
||||
if (!isRecord(parsed) || typeof parsed.id !== "number") return;
|
||||
const callbacks = pending.get(parsed.id);
|
||||
if (!callbacks) return;
|
||||
pending.delete(parsed.id);
|
||||
if (callbacks.timer) clearTimeout(callbacks.timer);
|
||||
if (isRecord(parsed.error)) {
|
||||
callbacks.reject(new Error(stringField(parsed.error.message) || "CDP call failed."));
|
||||
} else {
|
||||
callbacks.resolve(parsed.result);
|
||||
}
|
||||
});
|
||||
socket.addEventListener("error", () => {
|
||||
const error = new Error("CDP websocket failed.");
|
||||
closed = true;
|
||||
rejectPending(error);
|
||||
if (!opened && !settled) {
|
||||
settled = true;
|
||||
if (connectTimer) clearTimeout(connectTimer);
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
socket.addEventListener("close", () => {
|
||||
const error = new Error("CDP websocket closed.");
|
||||
closed = true;
|
||||
rejectPending(error);
|
||||
if (!opened && !settled) {
|
||||
settled = true;
|
||||
if (connectTimer) clearTimeout(connectTimer);
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function evaluate(
|
||||
client: CdpClient,
|
||||
expression: string,
|
||||
{ awaitPromise = false, timeoutMs = DEFAULT_CDP_PROBE_TIMEOUT_MS }: EvaluateOptions = {},
|
||||
): Promise<unknown> {
|
||||
const payload = await client.send("Runtime.evaluate", {
|
||||
expression,
|
||||
awaitPromise,
|
||||
returnByValue: true,
|
||||
}, { timeoutMs });
|
||||
return runtimeResultValue(payload);
|
||||
}
|
||||
|
||||
function runtimeResultValue(payload: unknown): unknown {
|
||||
if (!isRecord(payload)) return undefined;
|
||||
if (isRecord(payload.exceptionDetails)) {
|
||||
const exception = payload.exceptionDetails.exception;
|
||||
throw new Error(
|
||||
(isRecord(exception) && stringField(exception.description)) ||
|
||||
stringField(payload.exceptionDetails.text) ||
|
||||
"Evaluation failed.",
|
||||
);
|
||||
}
|
||||
const result = payload.result;
|
||||
return isRecord(result) ? result.value : undefined;
|
||||
}
|
||||
|
||||
export async function callFunction(
|
||||
client: CdpClient,
|
||||
functionDeclaration: string,
|
||||
args: readonly CdpFunctionArgument[] = [],
|
||||
{ awaitPromise = false, timeoutMs = DEFAULT_CDP_PROBE_TIMEOUT_MS }: EvaluateOptions = {},
|
||||
): Promise<unknown> {
|
||||
const receiverPayload = await client.send("Runtime.evaluate", {
|
||||
expression: "globalThis",
|
||||
returnByValue: false,
|
||||
}, { timeoutMs });
|
||||
if (!isRecord(receiverPayload) || !isRecord(receiverPayload.result)) {
|
||||
throw new Error("CDP did not return the page global object.");
|
||||
}
|
||||
if (isRecord(receiverPayload.exceptionDetails)) runtimeResultValue(receiverPayload);
|
||||
const objectId = stringField(receiverPayload.result.objectId);
|
||||
if (!objectId) throw new Error("CDP did not identify the page global object.");
|
||||
|
||||
const payload = await client.send("Runtime.callFunctionOn", {
|
||||
functionDeclaration,
|
||||
objectId,
|
||||
arguments: args.map((value) => ({ value })),
|
||||
awaitPromise,
|
||||
returnByValue: true,
|
||||
}, { timeoutMs });
|
||||
return runtimeResultValue(payload);
|
||||
}
|
||||
|
||||
export async function navigate(client: CdpClient, url: string): Promise<void> {
|
||||
await client.send("Page.navigate", { url });
|
||||
}
|
||||
|
||||
export async function captureScreenshot(client: CdpClient): Promise<Buffer> {
|
||||
const payload = await client.send("Page.captureScreenshot", { format: "png" });
|
||||
if (!isRecord(payload) || typeof payload.data !== "string") {
|
||||
throw new Error("Page.captureScreenshot did not return base64 PNG data.");
|
||||
}
|
||||
return Buffer.from(payload.data, "base64");
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export * from "./app-state.ts";
|
||||
export * from "./cdp.ts";
|
||||
export * from "./ports.ts";
|
||||
export * from "./screen.ts";
|
||||
export * from "./screen-state.ts";
|
||||
export * from "./surface.ts";
|
||||
export * from "./targets.ts";
|
||||
@@ -0,0 +1,54 @@
|
||||
import { createServer } from "node:net";
|
||||
import type { AddressInfo, Server } from "node:net";
|
||||
|
||||
function portFromAddress(address: string | AddressInfo | null): number {
|
||||
if (typeof address === "object" && address !== null) return address.port;
|
||||
throw new Error("Allocated port server did not expose a TCP address.");
|
||||
}
|
||||
|
||||
function closeServer(server: Server): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error) reject(error);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function openPortServer(): Promise<{ server: Server; port: number }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = createServer();
|
||||
let settled = false;
|
||||
server.once("error", (error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
reject(error);
|
||||
});
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve({ server, port: portFromAddress(server.address()) });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function allocateFreePort(): Promise<number> {
|
||||
const { server, port } = await openPortServer();
|
||||
await closeServer(server);
|
||||
return port;
|
||||
}
|
||||
|
||||
export async function allocateFreePorts(count: number): Promise<number[]> {
|
||||
if (!Number.isInteger(count) || count < 0) {
|
||||
throw new Error(`Port count must be a non-negative integer, got ${count}.`);
|
||||
}
|
||||
const opened: { server: Server; port: number }[] = [];
|
||||
try {
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
opened.push(await openPortServer());
|
||||
}
|
||||
return opened.map((entry) => entry.port);
|
||||
} finally {
|
||||
await Promise.all(opened.map((entry) => closeServer(entry.server).catch(() => undefined)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { evaluate } from "./cdp.ts";
|
||||
import type { Surface } from "./surface.ts";
|
||||
|
||||
const SCREEN_DUMP_TIMEOUT_MS = 8_000;
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function stringField(value: Record<string, unknown>, key: string): string {
|
||||
return typeof value[key] === "string" ? value[key] : "";
|
||||
}
|
||||
|
||||
function stringArrayField(value: Record<string, unknown>, key: string): string[] {
|
||||
const entries = value[key];
|
||||
return Array.isArray(entries) ? entries.filter((entry): entry is string => typeof entry === "string").slice(0, 30) : [];
|
||||
}
|
||||
|
||||
const SCREEN_DUMP_EXPRESSION = `(() => {
|
||||
const hash = window.location.hash;
|
||||
return {
|
||||
hash,
|
||||
route: hash.replace(/^#/, "") || window.location.pathname,
|
||||
title: document.title,
|
||||
buttons: [...document.querySelectorAll("button")]
|
||||
.map((button) => (button.textContent ?? "").replace(/\\s+/g, " ").trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 30),
|
||||
body: (document.body?.innerText ?? "").slice(0, 500),
|
||||
};
|
||||
})()`;
|
||||
|
||||
/** A short, best-effort snapshot suitable for appending to timeout errors. */
|
||||
export async function dumpScreenState(surface: Surface): Promise<string> {
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
try {
|
||||
const timedOut = new Promise<never>((_resolve, reject) => {
|
||||
timer = setTimeout(() => reject(new Error("screen dump timed out")), SCREEN_DUMP_TIMEOUT_MS);
|
||||
});
|
||||
const value = await Promise.race([
|
||||
evaluate(surface.client, SCREEN_DUMP_EXPRESSION, { timeoutMs: SCREEN_DUMP_TIMEOUT_MS }),
|
||||
timedOut,
|
||||
]);
|
||||
if (!isRecord(value)) return "unavailable";
|
||||
return JSON.stringify({
|
||||
hash: stringField(value, "hash"),
|
||||
route: stringField(value, "route"),
|
||||
title: stringField(value, "title"),
|
||||
buttons: stringArrayField(value, "buttons"),
|
||||
body: stringField(value, "body").slice(0, 500),
|
||||
});
|
||||
} catch {
|
||||
return "unavailable";
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { evaluate } from "./cdp.ts";
|
||||
import type { Surface } from "./surface.ts";
|
||||
|
||||
export interface Viewport {
|
||||
width: number;
|
||||
height: number;
|
||||
deviceScaleFactor: number;
|
||||
}
|
||||
|
||||
export async function setViewport(surface: Surface, viewport: Viewport): Promise<void> {
|
||||
await surface.client.send("Emulation.setDeviceMetricsOverride", {
|
||||
width: viewport.width,
|
||||
height: viewport.height,
|
||||
deviceScaleFactor: viewport.deviceScaleFactor,
|
||||
mobile: false,
|
||||
});
|
||||
}
|
||||
|
||||
export async function emulateFocus(surface: Surface): Promise<void> {
|
||||
await surface.client.send("Emulation.setFocusEmulationEnabled", { enabled: true });
|
||||
}
|
||||
|
||||
export async function paintBackdrop(surface: Surface, color: string): Promise<void> {
|
||||
await evaluate(surface.client, `(() => {
|
||||
if (!document.getElementById("docs-shots-backdrop")) {
|
||||
const style = document.createElement("style");
|
||||
style.id = "docs-shots-backdrop";
|
||||
style.textContent = "html { background-color: ${color} !important; }";
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
return true;
|
||||
})()`);
|
||||
}
|
||||
|
||||
/** Stop CSS motion and text carets so two clean frames can be pixel-identical. */
|
||||
export async function freezeMotion(surface: Surface): Promise<void> {
|
||||
await evaluate(surface.client, `(() => {
|
||||
if (!document.getElementById("docs-shots-freeze")) {
|
||||
const style = document.createElement("style");
|
||||
style.id = "docs-shots-freeze";
|
||||
style.textContent = "*, *::before, *::after { animation: none !important; transition: none !important; caret-color: transparent !important; scroll-behavior: auto !important; }";
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
return true;
|
||||
})()`);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import { probeAppState } from "./app-state.ts";
|
||||
import { callFunction, DEFAULT_CDP_PROBE_TIMEOUT_MS, connect, debuggerUrlFor, evaluate, pickAppTarget } from "./cdp.ts";
|
||||
import { firstPageTarget, targetById, waitForCdp } from "./targets.ts";
|
||||
import type { AppStateProbe } from "./app-state.ts";
|
||||
import type { CdpClient, CdpFunctionArgument, CdpTarget, EvaluateOptions } from "./cdp.ts";
|
||||
|
||||
export type SurfaceKind = "electron" | "chrome";
|
||||
|
||||
export interface SurfaceHandle {
|
||||
name: string;
|
||||
kind: SurfaceKind;
|
||||
hostKind: string;
|
||||
cdpUrl: string;
|
||||
pid?: number;
|
||||
profileDir?: string;
|
||||
sandboxId?: string;
|
||||
meta?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface Surface {
|
||||
handle: SurfaceHandle;
|
||||
client: CdpClient;
|
||||
}
|
||||
|
||||
export interface AttachedSurface extends Surface, AsyncDisposable {
|
||||
stop(): Promise<void>;
|
||||
}
|
||||
|
||||
const REATTACH_FLOOR_MS = 8_000;
|
||||
const HTTP_LIVENESS_TIMEOUT_MS = 4_000;
|
||||
|
||||
async function connectToAppTarget(handle: SurfaceHandle, timeoutMs = 30_000, preferredTargetId?: string): Promise<CdpClient> {
|
||||
const startedAt = Date.now();
|
||||
const remaining = () => Math.max(1, timeoutMs - (Date.now() - startedAt));
|
||||
const fallbackTarget = () => handle.kind === "electron"
|
||||
? pickAppTarget(handle.cdpUrl, { timeoutMs: remaining() })
|
||||
: firstPageTarget(handle.cdpUrl, { timeoutMs: remaining() });
|
||||
const target: CdpTarget = preferredTargetId
|
||||
? await targetById(handle.cdpUrl, preferredTargetId, { timeoutMs: remaining() }).catch(fallbackTarget)
|
||||
: await fallbackTarget();
|
||||
const client = await connect(debuggerUrlFor(handle.cdpUrl, target), {
|
||||
connectTimeoutMs: remaining(),
|
||||
sendTimeoutMs: remaining(),
|
||||
});
|
||||
await client.send("Page.enable", {}, { timeoutMs: remaining() }).catch(() => undefined);
|
||||
return client;
|
||||
}
|
||||
|
||||
export async function attachSurface(handle: SurfaceHandle, opts: { timeoutMs?: number } = {}): Promise<AttachedSurface> {
|
||||
const timeoutMs = opts.timeoutMs ?? 30_000;
|
||||
const startedAt = Date.now();
|
||||
await waitForCdp(handle.cdpUrl, { timeoutMs });
|
||||
const client = await connectToAppTarget(handle, Math.max(1, timeoutMs - (Date.now() - startedAt)));
|
||||
const surface: AttachedSurface = {
|
||||
handle,
|
||||
client,
|
||||
stop: async () => surface.client.close(),
|
||||
[Symbol.asyncDispose]: async () => surface.client.close(),
|
||||
};
|
||||
return surface;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-attach to the app's CURRENT page target.
|
||||
*
|
||||
* The desktop recreates its page target during some transitions (finishing
|
||||
* onboarding, for example). Evaluations against the old target then hang rather
|
||||
* than fail, which looks exactly like a blocked renderer. The legacy runner had
|
||||
* the same escape hatch as `ctx.reconnect()`.
|
||||
*/
|
||||
export async function reattachSurface(surface: Surface, opts: { timeoutMs?: number } = {}): Promise<void> {
|
||||
const targetId = surface.client.targetId ?? undefined;
|
||||
try {
|
||||
surface.client.close();
|
||||
} catch {
|
||||
// The old client is already gone; that is the case we are recovering from.
|
||||
}
|
||||
const client = await connectToAppTarget(surface.handle, Math.max(1, opts.timeoutMs ?? 30_000), targetId);
|
||||
surface.client = client;
|
||||
}
|
||||
|
||||
function isRecoverableTransportError(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) return false;
|
||||
return /timed out|timeout|CDP socket is not open|CDP transport stalled|CDP websocket (failed|closed)/i.test(error.message);
|
||||
}
|
||||
|
||||
async function isCdpHttpAlive(cdpUrl: string): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`${cdpUrl.replace(/\/$/, "")}/json/version`, {
|
||||
signal: AbortSignal.timeout(HTTP_LIVENESS_TIMEOUT_MS),
|
||||
});
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function readOnSurface<T>(
|
||||
surface: Surface,
|
||||
read: (client: CdpClient, timeoutMs: number) => Promise<T>,
|
||||
{ timeoutMs, reattachAttempts }: { timeoutMs: number; reattachAttempts: number },
|
||||
): Promise<T> {
|
||||
const startedAt = Date.now();
|
||||
const remaining = () => Math.max(0, timeoutMs - (Date.now() - startedAt));
|
||||
let lastError: unknown = null;
|
||||
for (let attempt = 0; attempt <= reattachAttempts; attempt += 1) {
|
||||
try {
|
||||
const attemptTimeoutMs = attempt === 0 ? Math.max(1, remaining()) : Math.max(remaining(), REATTACH_FLOOR_MS);
|
||||
return await read(surface.client, attemptTimeoutMs);
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (attempt === reattachAttempts || !isRecoverableTransportError(error)) break;
|
||||
if (!await isCdpHttpAlive(surface.handle.cdpUrl)) throw error;
|
||||
surface.client.abort?.(error instanceof Error ? error : undefined);
|
||||
await reattachSurface(surface, { timeoutMs: Math.max(remaining(), REATTACH_FLOOR_MS) }).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
throw lastError instanceof Error ? lastError : new Error("Surface read failed.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate against a surface, healing a replaced page target.
|
||||
*
|
||||
* The desktop swaps its page target during transitions; evaluations against the
|
||||
* old one hang until they time out, which reads like a blocked renderer. Owning
|
||||
* that here means callers — behaviours, specs, the readiness gate — never carry
|
||||
* re-attach bookkeeping.
|
||||
*/
|
||||
export async function evaluateOnSurface(
|
||||
surface: Surface,
|
||||
expression: string,
|
||||
opts: EvaluateOptions & { reattachAttempts?: number } = {},
|
||||
): Promise<unknown> {
|
||||
const { reattachAttempts = 1, timeoutMs = DEFAULT_CDP_PROBE_TIMEOUT_MS, ...evaluateOptions } = opts;
|
||||
return readOnSurface(
|
||||
surface,
|
||||
(client, attemptTimeoutMs) => evaluate(client, expression, { ...evaluateOptions, timeoutMs: attemptTimeoutMs }),
|
||||
{ timeoutMs: Math.max(1, timeoutMs), reattachAttempts },
|
||||
);
|
||||
}
|
||||
|
||||
export async function callFunctionOnSurface(
|
||||
surface: Surface,
|
||||
functionDeclaration: string,
|
||||
args: readonly CdpFunctionArgument[] = [],
|
||||
opts: EvaluateOptions & { reattachAttempts?: number } = {},
|
||||
): Promise<unknown> {
|
||||
const { reattachAttempts = 1, timeoutMs = DEFAULT_CDP_PROBE_TIMEOUT_MS, ...callOptions } = opts;
|
||||
return readOnSurface(
|
||||
surface,
|
||||
(client, attemptTimeoutMs) => callFunction(client, functionDeclaration, args, { ...callOptions, timeoutMs: attemptTimeoutMs }),
|
||||
{ timeoutMs: Math.max(1, timeoutMs), reattachAttempts },
|
||||
);
|
||||
}
|
||||
|
||||
export async function probeAppStateOnSurface(
|
||||
surface: Surface,
|
||||
opts: EvaluateOptions & { reattachAttempts?: number } = {},
|
||||
): Promise<AppStateProbe> {
|
||||
const { reattachAttempts = 1, timeoutMs = DEFAULT_CDP_PROBE_TIMEOUT_MS, ...probeOptions } = opts;
|
||||
return readOnSurface(
|
||||
surface,
|
||||
(client, attemptTimeoutMs) => probeAppState(client, { ...probeOptions, timeoutMs: attemptTimeoutMs }),
|
||||
{ timeoutMs: Math.max(1, timeoutMs), reattachAttempts },
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { DEFAULT_CDP_PROBE_TIMEOUT_MS, listTargets } from "./cdp.ts";
|
||||
import type { CdpTarget } from "./cdp.ts";
|
||||
|
||||
const DEFAULT_CDP_WAIT_MS = 20_000;
|
||||
const POLL_INTERVAL_MS = 250;
|
||||
|
||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
function cleanBaseUrl(cdpBaseUrl: string): string {
|
||||
let end = cdpBaseUrl.length;
|
||||
while (end > 0 && cdpBaseUrl[end - 1] === "/") end -= 1;
|
||||
return cdpBaseUrl.slice(0, end);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function stringField(value: unknown): string {
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
function targetFromUnknown(value: unknown): CdpTarget | null {
|
||||
if (!isRecord(value)) return null;
|
||||
if (value.type !== "page" || typeof value.webSocketDebuggerUrl !== "string" || !value.webSocketDebuggerUrl) return null;
|
||||
return {
|
||||
id: stringField(value.id),
|
||||
type: "page",
|
||||
title: stringField(value.title),
|
||||
url: stringField(value.url),
|
||||
webSocketDebuggerUrl: value.webSocketDebuggerUrl,
|
||||
};
|
||||
}
|
||||
|
||||
function messageText(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
export async function firstPageTarget(
|
||||
cdpBaseUrl: string,
|
||||
{ timeoutMs = DEFAULT_CDP_PROBE_TIMEOUT_MS }: { timeoutMs?: number } = {},
|
||||
): Promise<CdpTarget> {
|
||||
const startedAt = Date.now();
|
||||
const existing = await listTargets(cdpBaseUrl, { timeoutMs });
|
||||
const page = existing.find((target) => target.type === "page" && target.webSocketDebuggerUrl);
|
||||
if (page) return page;
|
||||
return newPageTarget(cdpBaseUrl, "about:blank", { timeoutMs: Math.max(0, timeoutMs - (Date.now() - startedAt)) });
|
||||
}
|
||||
|
||||
export async function targetById(
|
||||
cdpBaseUrl: string,
|
||||
targetId: string,
|
||||
{ timeoutMs = DEFAULT_CDP_PROBE_TIMEOUT_MS }: { timeoutMs?: number } = {},
|
||||
): Promise<CdpTarget> {
|
||||
const targets = await listTargets(cdpBaseUrl, { timeoutMs });
|
||||
const target = targets.find((entry) => entry.id === targetId && entry.type === "page" && entry.webSocketDebuggerUrl);
|
||||
if (!target) {
|
||||
throw new Error(`No page target ${targetId} available at ${cdpBaseUrl}.`);
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
export async function newPageTarget(
|
||||
cdpBaseUrl: string,
|
||||
url = "about:blank",
|
||||
{ timeoutMs = DEFAULT_CDP_PROBE_TIMEOUT_MS }: { timeoutMs?: number } = {},
|
||||
): Promise<CdpTarget> {
|
||||
const base = cleanBaseUrl(cdpBaseUrl);
|
||||
const startedAt = Date.now();
|
||||
const remaining = () => Math.max(0, timeoutMs - (Date.now() - startedAt));
|
||||
let response = await fetch(`${base}/json/new?${encodeURIComponent(url)}`, {
|
||||
method: "PUT",
|
||||
signal: AbortSignal.timeout(remaining()),
|
||||
});
|
||||
if (!response.ok) {
|
||||
response = await fetch(`${base}/json/new?${encodeURIComponent(url)}`, {
|
||||
signal: AbortSignal.timeout(remaining()),
|
||||
});
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(`Could not create a page target at ${cdpBaseUrl}: ${response.status}`);
|
||||
}
|
||||
const created = targetFromUnknown(await response.json());
|
||||
if (created) return created;
|
||||
const targets = await listTargets(cdpBaseUrl, { timeoutMs: remaining() });
|
||||
const nextPage = targets.find((target) => target.type === "page" && target.webSocketDebuggerUrl);
|
||||
if (!nextPage) {
|
||||
throw new Error(`No page target available at ${cdpBaseUrl}.`);
|
||||
}
|
||||
return nextPage;
|
||||
}
|
||||
|
||||
export async function activateTarget(
|
||||
cdpBaseUrl: string,
|
||||
targetId: string,
|
||||
{ timeoutMs = DEFAULT_CDP_PROBE_TIMEOUT_MS }: { timeoutMs?: number } = {},
|
||||
): Promise<void> {
|
||||
if (!targetId) return;
|
||||
const base = cleanBaseUrl(cdpBaseUrl);
|
||||
await fetch(`${base}/json/activate/${encodeURIComponent(targetId)}`, {
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
}).catch(() => undefined);
|
||||
}
|
||||
|
||||
export async function closeTarget(
|
||||
cdpBaseUrl: string,
|
||||
targetId: string,
|
||||
{ timeoutMs = DEFAULT_CDP_PROBE_TIMEOUT_MS }: { timeoutMs?: number } = {},
|
||||
): Promise<void> {
|
||||
if (!targetId) return;
|
||||
const base = cleanBaseUrl(cdpBaseUrl);
|
||||
await fetch(`${base}/json/close/${encodeURIComponent(targetId)}`, {
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
}).catch(() => undefined);
|
||||
}
|
||||
|
||||
export async function waitForCdp(cdpBaseUrl: string, { timeoutMs = DEFAULT_CDP_WAIT_MS }: { timeoutMs?: number } = {}): Promise<void> {
|
||||
const startedAt = Date.now();
|
||||
let lastError: unknown = null;
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
try {
|
||||
await listTargets(cdpBaseUrl, {
|
||||
timeoutMs: Math.min(DEFAULT_CDP_PROBE_TIMEOUT_MS, timeoutMs - (Date.now() - startedAt)),
|
||||
});
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
await sleep(Math.min(POLL_INTERVAL_MS, Math.max(0, timeoutMs - (Date.now() - startedAt))));
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
`Timed out after ${timeoutMs}ms waiting for CDP at ${cdpBaseUrl}` +
|
||||
(lastError ? ` (last error: ${messageText(lastError)})` : ""),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import { createServer } from "node:http";
|
||||
import test from "node:test";
|
||||
import type { Server } from "node:http";
|
||||
import type { Duplex } from "node:stream";
|
||||
|
||||
import { callFunction, connect, evaluate } from "../src/cdp.ts";
|
||||
import { evaluateOnSurface } from "../src/surface.ts";
|
||||
import type { Surface } from "../src/surface.ts";
|
||||
|
||||
const TARGET_ID = "page-1";
|
||||
const WEBSOCKET_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function portFromServer(server: Server): number {
|
||||
const address = server.address();
|
||||
if (typeof address === "object" && address !== null) return address.port;
|
||||
throw new Error("Test server did not expose a TCP port.");
|
||||
}
|
||||
|
||||
function websocketFrame(payload: string): Buffer {
|
||||
const body = Buffer.from(payload);
|
||||
if (body.length < 126) return Buffer.concat([Buffer.from([0x81, body.length]), body]);
|
||||
const header = Buffer.alloc(4);
|
||||
header[0] = 0x81;
|
||||
header[1] = 126;
|
||||
header.writeUInt16BE(body.length, 2);
|
||||
return Buffer.concat([header, body]);
|
||||
}
|
||||
|
||||
function handleClientFrames(socket: Duplex): void {
|
||||
let buffered = Buffer.alloc(0);
|
||||
socket.on("data", (chunk: Buffer) => {
|
||||
buffered = Buffer.concat([buffered, chunk]);
|
||||
while (buffered.length >= 2) {
|
||||
const lengthCode = buffered[1] & 0x7f;
|
||||
const lengthBytes = lengthCode === 126 ? 2 : 0;
|
||||
if (lengthCode === 127) throw new Error("Test websocket frame is unexpectedly large.");
|
||||
const headerLength = 2 + lengthBytes + 4;
|
||||
if (buffered.length < headerLength) return;
|
||||
const payloadLength = lengthCode === 126 ? buffered.readUInt16BE(2) : lengthCode;
|
||||
if (buffered.length < headerLength + payloadLength) return;
|
||||
const maskOffset = 2 + lengthBytes;
|
||||
const payload = Buffer.alloc(payloadLength);
|
||||
for (let index = 0; index < payloadLength; index += 1) {
|
||||
payload[index] = buffered[headerLength + index] ^ buffered[maskOffset + (index % 4)];
|
||||
}
|
||||
buffered = buffered.subarray(headerLength + payloadLength);
|
||||
const message: unknown = JSON.parse(payload.toString());
|
||||
if (!isRecord(message) || typeof message.id !== "number") continue;
|
||||
let result = {};
|
||||
if (message.method === "Runtime.evaluate") {
|
||||
const params = isRecord(message.params) ? message.params : {};
|
||||
result = params.expression === "globalThis"
|
||||
? { result: { objectId: "global-object" } }
|
||||
: { result: { value: "recovered" } };
|
||||
} else if (message.method === "Runtime.callFunctionOn") {
|
||||
const params = isRecord(message.params) ? message.params : {};
|
||||
const args = Array.isArray(params.arguments) ? params.arguments : [];
|
||||
const first = args[0];
|
||||
result = { result: { value: isRecord(first) ? first.value : undefined } };
|
||||
}
|
||||
socket.write(websocketFrame(JSON.stringify({ id: message.id, result })));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function startCdpServer(stalledConnections: number): Promise<{
|
||||
baseUrl: string;
|
||||
websocketUrl: string;
|
||||
connections(): number;
|
||||
close(): Promise<void>;
|
||||
}> {
|
||||
const sockets = new Set<Duplex>();
|
||||
let connectionCount = 0;
|
||||
const server = createServer((request, response) => {
|
||||
if (request.url === "/json/version") {
|
||||
response.writeHead(200, { "content-type": "application/json" });
|
||||
response.end(JSON.stringify({ Browser: "test" }));
|
||||
return;
|
||||
}
|
||||
if (request.url === "/json/list") {
|
||||
const port = portFromServer(server);
|
||||
response.writeHead(200, { "content-type": "application/json" });
|
||||
response.end(JSON.stringify([{
|
||||
id: TARGET_ID,
|
||||
type: "page",
|
||||
title: "OpenWork",
|
||||
url: "http://127.0.0.1/app",
|
||||
webSocketDebuggerUrl: `ws://127.0.0.1:${port}/devtools/page/${TARGET_ID}`,
|
||||
}]));
|
||||
return;
|
||||
}
|
||||
response.writeHead(404);
|
||||
response.end("not found");
|
||||
});
|
||||
server.on("upgrade", (request, socket) => {
|
||||
const key = request.headers["sec-websocket-key"];
|
||||
if (typeof key !== "string") {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
sockets.add(socket);
|
||||
socket.once("close", () => sockets.delete(socket));
|
||||
const accept = createHash("sha1").update(`${key}${WEBSOCKET_GUID}`).digest("base64");
|
||||
socket.write([
|
||||
"HTTP/1.1 101 Switching Protocols",
|
||||
"Upgrade: websocket",
|
||||
"Connection: Upgrade",
|
||||
`Sec-WebSocket-Accept: ${accept}`,
|
||||
"",
|
||||
"",
|
||||
].join("\r\n"));
|
||||
connectionCount += 1;
|
||||
if (connectionCount > stalledConnections) handleClientFrames(socket);
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", resolve);
|
||||
});
|
||||
const port = portFromServer(server);
|
||||
return {
|
||||
baseUrl: `http://127.0.0.1:${port}`,
|
||||
websocketUrl: `ws://127.0.0.1:${port}/devtools/page/${TARGET_ID}`,
|
||||
connections: () => connectionCount,
|
||||
close: async () => {
|
||||
for (const socket of sockets) socket.destroy();
|
||||
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("a stalled websocket can be condemned without awaiting close", async () => {
|
||||
const server = await startCdpServer(1);
|
||||
try {
|
||||
const client = await connect(server.websocketUrl, { connectTimeoutMs: 500, sendTimeoutMs: 500 });
|
||||
await assert.rejects(evaluate(client, "1", { timeoutMs: 30 }), /CDP call Runtime\.evaluate timed out/);
|
||||
|
||||
const pending = evaluate(client, "2", { timeoutMs: 500 });
|
||||
if (!client.abort) throw new Error("Connected CDP client did not expose abort().");
|
||||
const startedAt = Date.now();
|
||||
client.abort(new Error("probe timed out"));
|
||||
assert.ok(Date.now() - startedAt < 100, "abort() waited for the peer to complete websocket close");
|
||||
await assert.rejects(pending, /CDP transport stalled: probe timed out/);
|
||||
await assert.rejects(evaluate(client, "3", { timeoutMs: 500 }), /CDP socket is not open/);
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("function calls pass dynamic values as structured CDP arguments", async () => {
|
||||
const server = await startCdpServer(0);
|
||||
const client = await connect(server.websocketUrl, { connectTimeoutMs: 500, sendTimeoutMs: 500 });
|
||||
try {
|
||||
const dynamicValue = `"); throw new Error("must stay data"); //`;
|
||||
const value = await callFunction(client, "function (value) { return value; }", [dynamicValue]);
|
||||
assert.equal(value, dynamicValue);
|
||||
} finally {
|
||||
client.close();
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("surface evaluation heals after the first websocket consumes its budget", async () => {
|
||||
const server = await startCdpServer(1);
|
||||
try {
|
||||
const firstClient = await connect(server.websocketUrl, { connectTimeoutMs: 500, sendTimeoutMs: 500 });
|
||||
const surface: Surface = {
|
||||
handle: { name: "test", kind: "electron", hostKind: "test", cdpUrl: server.baseUrl },
|
||||
client: firstClient,
|
||||
};
|
||||
|
||||
const value = await evaluateOnSurface(surface, "42", { timeoutMs: 30 });
|
||||
|
||||
assert.equal(value, "recovered");
|
||||
assert.notEqual(surface.client, firstClient);
|
||||
assert.equal(server.connections(), 2);
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user