Files
openclaw/extensions/qa-lab/src/browser-runtime.ts
2026-04-12 19:41:06 -07:00

213 lines
5.2 KiB
TypeScript

type QaBrowserGateway = {
call: (
method: string,
params: Record<string, unknown>,
opts?: { timeoutMs?: number },
) => Promise<unknown>;
};
type QaBrowserEnv = {
gateway: QaBrowserGateway;
};
type QaBrowserRequestParams = {
method: "GET" | "POST" | "DELETE";
path: string;
query?: Record<string, string | number | boolean | undefined>;
body?: unknown;
timeoutMs?: number;
};
type QaBrowserOpenTabParams = {
url: string;
profile?: string;
timeoutMs?: number;
};
type QaBrowserSnapshotParams = {
profile?: string;
targetId?: string;
format?: "ai" | "aria";
limit?: number;
interactive?: boolean;
compact?: boolean;
depth?: number;
selector?: string;
frame?: string;
labels?: boolean;
mode?: "efficient";
maxChars?: number;
timeoutMs?: number;
};
type QaBrowserActRequest = {
kind: string;
targetId?: string;
ref?: string;
doubleClick?: boolean;
button?: string;
modifiers?: string[];
text?: string;
submit?: boolean;
slowly?: boolean;
key?: string;
delayMs?: number;
startRef?: string;
endRef?: string;
values?: string[];
fields?: Array<Record<string, unknown>>;
width?: number;
height?: number;
timeMs?: number;
selector?: string;
url?: string;
loadState?: string;
textGone?: string;
timeoutMs?: number;
fn?: string;
};
type QaBrowserActParams = {
profile?: string;
request: QaBrowserActRequest;
timeoutMs?: number;
};
type QaBrowserStatus = {
enabled?: boolean;
running?: boolean;
cdpReady?: boolean;
};
type QaBrowserReadyParams = {
profile?: string;
timeoutMs?: number;
intervalMs?: number;
};
function normalizeBrowserQuery(
query: QaBrowserRequestParams["query"],
): Record<string, string> | undefined {
if (!query) {
return undefined;
}
const normalized = Object.fromEntries(
Object.entries(query)
.filter(([, value]) => value !== undefined)
.map(([key, value]) => [key, String(value)]),
);
return Object.keys(normalized).length > 0 ? normalized : undefined;
}
function resolveBrowserTimeoutMs(timeoutMs: number | undefined, fallbackMs: number) {
if (typeof timeoutMs !== "number" || !Number.isFinite(timeoutMs)) {
return fallbackMs;
}
return Math.max(1, Math.floor(timeoutMs));
}
export async function callQaBrowserRequest<T = unknown>(
env: QaBrowserEnv,
params: QaBrowserRequestParams,
): Promise<T> {
const timeoutMs = resolveBrowserTimeoutMs(params.timeoutMs, 20_000);
const payload = await env.gateway.call(
"browser.request",
{
method: params.method,
path: params.path,
query: normalizeBrowserQuery(params.query),
body: params.body,
timeoutMs,
},
{ timeoutMs },
);
return payload as T;
}
export async function qaBrowserOpenTab<T = unknown>(
env: QaBrowserEnv,
params: QaBrowserOpenTabParams,
): Promise<T> {
return await callQaBrowserRequest<T>(env, {
method: "POST",
path: "/tabs/open",
query: params.profile ? { profile: params.profile } : undefined,
body: { url: params.url },
timeoutMs: resolveBrowserTimeoutMs(params.timeoutMs, 20_000),
});
}
export async function qaBrowserSnapshot<T = unknown>(
env: QaBrowserEnv,
params: QaBrowserSnapshotParams = {},
): Promise<T> {
return await callQaBrowserRequest<T>(env, {
method: "GET",
path: "/snapshot",
query: {
profile: params.profile,
targetId: params.targetId,
format: params.format ?? "ai",
limit: params.limit,
interactive: params.interactive,
compact: params.compact,
depth: params.depth,
selector: params.selector,
frame: params.frame,
labels: params.labels,
mode: params.mode,
maxChars: params.maxChars,
},
timeoutMs: resolveBrowserTimeoutMs(params.timeoutMs, 20_000),
});
}
export async function qaBrowserAct<T = unknown>(
env: QaBrowserEnv,
params: QaBrowserActParams,
): Promise<T> {
return await callQaBrowserRequest<T>(env, {
method: "POST",
path: "/act",
query: params.profile ? { profile: params.profile } : undefined,
body: params.request,
timeoutMs: resolveBrowserTimeoutMs(params.timeoutMs, 20_000),
});
}
function isQaBrowserReady(status: QaBrowserStatus | null | undefined) {
return status?.enabled === true && status?.running === true && status?.cdpReady === true;
}
function sleep(ms: number) {
return new Promise<void>((resolve) => setTimeout(resolve, ms));
}
export async function waitForQaBrowserReady<T extends QaBrowserStatus = QaBrowserStatus>(
env: QaBrowserEnv,
params: QaBrowserReadyParams = {},
): Promise<T> {
const timeoutMs = resolveBrowserTimeoutMs(params.timeoutMs, 20_000);
const intervalMs = resolveBrowserTimeoutMs(params.intervalMs, 250);
const startedAt = Date.now();
let lastStatus: QaBrowserStatus | null = null;
while (Date.now() - startedAt < timeoutMs) {
lastStatus = await callQaBrowserRequest<QaBrowserStatus>(env, {
method: "GET",
path: "/",
query: params.profile ? { profile: params.profile } : undefined,
timeoutMs: Math.min(timeoutMs, 5_000),
});
if (isQaBrowserReady(lastStatus)) {
return lastStatus as T;
}
await sleep(intervalMs);
}
throw new Error(
`browser control not ready after ${timeoutMs}ms${
lastStatus ? ` (${JSON.stringify(lastStatus)})` : ""
}`,
);
}