mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 09:20:43 +00:00
feat(browser): add qa web runtime support
This commit is contained in:
@@ -20,22 +20,33 @@ type ChromeMcpSessionFactory = Exclude<
|
||||
type ChromeMcpSession = Awaited<ReturnType<ChromeMcpSessionFactory>>;
|
||||
|
||||
function createFakeSession(): ChromeMcpSession {
|
||||
const callTool = vi.fn(async ({ name }: ToolCall) => {
|
||||
let currentUrl =
|
||||
"https://developer.chrome.com/blog/chrome-devtools-mcp-debug-your-browser-session";
|
||||
let createdPageOpen = false;
|
||||
const readUrlArg = (value: unknown, fallback: string) =>
|
||||
typeof value === "string" && value.trim() ? value : fallback;
|
||||
const callTool = vi.fn(async ({ name, arguments: args }: ToolCall) => {
|
||||
if (name === "list_pages") {
|
||||
const pageLines = [
|
||||
"## Pages",
|
||||
`1: ${currentUrl} [selected]`,
|
||||
"2: https://github.com/openclaw/openclaw/pull/45318",
|
||||
];
|
||||
if (createdPageOpen) {
|
||||
pageLines.push(`3: ${currentUrl}`);
|
||||
}
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: [
|
||||
"## Pages",
|
||||
"1: https://developer.chrome.com/blog/chrome-devtools-mcp-debug-your-browser-session [selected]",
|
||||
"2: https://github.com/openclaw/openclaw/pull/45318",
|
||||
].join("\n"),
|
||||
text: pageLines.join("\n"),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
if (name === "new_page") {
|
||||
currentUrl = readUrlArg(args?.url, "about:blank");
|
||||
createdPageOpen = true;
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
@@ -44,12 +55,16 @@ function createFakeSession(): ChromeMcpSession {
|
||||
"## Pages",
|
||||
"1: https://developer.chrome.com/blog/chrome-devtools-mcp-debug-your-browser-session",
|
||||
"2: https://github.com/openclaw/openclaw/pull/45318",
|
||||
"3: https://example.com/ [selected]",
|
||||
`3: ${currentUrl} [selected]`,
|
||||
].join("\n"),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
if (name === "navigate_page") {
|
||||
currentUrl = readUrlArg(args?.url, currentUrl);
|
||||
return { content: [{ type: "text", text: "navigated" }] };
|
||||
}
|
||||
if (name === "evaluate_script") {
|
||||
return {
|
||||
content: [
|
||||
@@ -130,6 +145,28 @@ describe("chrome MCP page parsing", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("opens about:blank directly without an extra navigate", async () => {
|
||||
const session = createFakeSession();
|
||||
const factory: ChromeMcpSessionFactory = async () => session;
|
||||
setChromeMcpSessionFactoryForTest(factory);
|
||||
|
||||
const tab = await openChromeMcpTab("chrome-live", "about:blank");
|
||||
|
||||
expect(tab).toEqual({
|
||||
targetId: "3",
|
||||
title: "",
|
||||
url: "about:blank",
|
||||
type: "page",
|
||||
});
|
||||
expect(session.client.callTool).toHaveBeenCalledWith({
|
||||
name: "new_page",
|
||||
arguments: { url: "about:blank", timeout: 5000 },
|
||||
});
|
||||
expect(session.client.callTool).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: "navigate_page" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("parses evaluate_script text responses when structuredContent is missing", async () => {
|
||||
const factory: ChromeMcpSessionFactory = async () => createFakeSession();
|
||||
setChromeMcpSessionFactoryForTest(factory);
|
||||
|
||||
@@ -42,6 +42,8 @@ const DEFAULT_CHROME_MCP_ARGS = [
|
||||
"--experimentalStructuredContent",
|
||||
"--experimental-page-id-routing",
|
||||
];
|
||||
const CHROME_MCP_NEW_PAGE_TIMEOUT_MS = 5_000;
|
||||
const CHROME_MCP_NAVIGATE_TIMEOUT_MS = 20_000;
|
||||
|
||||
const sessions = new Map<string, ChromeMcpSession>();
|
||||
const pendingSessions = new Map<string, Promise<ChromeMcpSession>>();
|
||||
@@ -401,16 +403,33 @@ export async function openChromeMcpTab(
|
||||
url: string,
|
||||
userDataDir?: string,
|
||||
): Promise<BrowserTab> {
|
||||
const result = await callTool(profileName, userDataDir, "new_page", { url });
|
||||
const targetUrl = url.trim() || "about:blank";
|
||||
const result = await callTool(profileName, userDataDir, "new_page", {
|
||||
url: "about:blank",
|
||||
timeout: CHROME_MCP_NEW_PAGE_TIMEOUT_MS,
|
||||
});
|
||||
const pages = extractStructuredPages(result);
|
||||
const chosen = pages.find((page) => page.selected) ?? pages.at(-1);
|
||||
if (!chosen) {
|
||||
throw new Error("Chrome MCP did not return the created page.");
|
||||
}
|
||||
const targetId = String(chosen.id);
|
||||
const finalUrl =
|
||||
targetUrl === "about:blank"
|
||||
? (chosen.url ?? targetUrl)
|
||||
: (
|
||||
await navigateChromeMcpPage({
|
||||
profileName,
|
||||
userDataDir,
|
||||
targetId,
|
||||
url: targetUrl,
|
||||
timeoutMs: CHROME_MCP_NAVIGATE_TIMEOUT_MS,
|
||||
})
|
||||
).url;
|
||||
return {
|
||||
targetId: String(chosen.id),
|
||||
targetId,
|
||||
title: "",
|
||||
url: chosen.url ?? url,
|
||||
url: finalUrl,
|
||||
type: "page",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const {
|
||||
createBrowserControlContextMock,
|
||||
createBrowserRouteDispatcherMock,
|
||||
loadConfigMock,
|
||||
startBrowserControlServiceFromConfigMock,
|
||||
withTimeoutMock,
|
||||
} = vi.hoisted(() => ({
|
||||
createBrowserControlContextMock: vi.fn(() => ({ ok: true })),
|
||||
createBrowserRouteDispatcherMock: vi.fn(),
|
||||
loadConfigMock: vi.fn(),
|
||||
startBrowserControlServiceFromConfigMock: vi.fn(),
|
||||
withTimeoutMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../core-api.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../core-api.js")>("../core-api.js");
|
||||
return {
|
||||
...actual,
|
||||
createBrowserControlContext: createBrowserControlContextMock,
|
||||
createBrowserRouteDispatcher: createBrowserRouteDispatcherMock,
|
||||
loadConfig: loadConfigMock,
|
||||
startBrowserControlServiceFromConfig: startBrowserControlServiceFromConfigMock,
|
||||
withTimeout: withTimeoutMock,
|
||||
};
|
||||
});
|
||||
|
||||
import { browserHandlers } from "./browser-request.js";
|
||||
|
||||
describe("browser.request local timeout", () => {
|
||||
beforeEach(() => {
|
||||
loadConfigMock.mockReturnValue({
|
||||
gateway: { nodes: { browser: { mode: "off" } } },
|
||||
});
|
||||
startBrowserControlServiceFromConfigMock.mockResolvedValue(true);
|
||||
createBrowserRouteDispatcherMock.mockReturnValue({
|
||||
dispatch: vi.fn(async () => ({ status: 200, body: { ok: true } })),
|
||||
});
|
||||
withTimeoutMock.mockImplementation(async () => {
|
||||
throw new Error("browser request timed out");
|
||||
});
|
||||
});
|
||||
|
||||
it("applies timeoutMs to local browser dispatches", async () => {
|
||||
const respond = vi.fn();
|
||||
|
||||
await browserHandlers["browser.request"]({
|
||||
params: {
|
||||
method: "POST",
|
||||
path: "/tabs/open",
|
||||
body: { url: "https://example.com" },
|
||||
timeoutMs: 4321,
|
||||
},
|
||||
respond: respond as never,
|
||||
context: {
|
||||
nodeRegistry: { listConnected: () => [] },
|
||||
} as never,
|
||||
client: null,
|
||||
req: { type: "req", id: "req-1", method: "browser.request" },
|
||||
isWebchatConnect: () => false,
|
||||
});
|
||||
|
||||
expect(withTimeoutMock).toHaveBeenCalledWith(expect.any(Function), 4321, "browser request");
|
||||
expect(respond).toHaveBeenCalledWith(
|
||||
false,
|
||||
undefined,
|
||||
expect.objectContaining({
|
||||
message: "Error: browser request timed out",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
respondUnavailableOnNodeInvokeError,
|
||||
safeParseJson,
|
||||
startBrowserControlServiceFromConfig,
|
||||
withTimeout,
|
||||
type GatewayRequestHandlers,
|
||||
type NodeSession,
|
||||
} from "../core-api.js";
|
||||
@@ -246,12 +247,31 @@ export async function handleBrowserGatewayRequest({
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await dispatcher.dispatch({
|
||||
method: methodRaw,
|
||||
path,
|
||||
query,
|
||||
body,
|
||||
});
|
||||
let result;
|
||||
try {
|
||||
result = timeoutMs
|
||||
? await withTimeout(
|
||||
(signal) =>
|
||||
dispatcher.dispatch({
|
||||
method: methodRaw,
|
||||
path,
|
||||
query,
|
||||
body,
|
||||
signal,
|
||||
}),
|
||||
timeoutMs,
|
||||
"browser request",
|
||||
)
|
||||
: await dispatcher.dispatch({
|
||||
method: methodRaw,
|
||||
path,
|
||||
query,
|
||||
body,
|
||||
});
|
||||
} catch (err) {
|
||||
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, String(err)));
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.status >= 400) {
|
||||
const message =
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
"private": true,
|
||||
"description": "OpenClaw QA lab plugin with private debugger UI and scenario runner",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.59.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@openclaw/plugin-sdk": "workspace:*",
|
||||
"openclaw": "workspace:*"
|
||||
|
||||
169
extensions/qa-lab/src/browser-runtime.test.ts
Normal file
169
extensions/qa-lab/src/browser-runtime.test.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
callQaBrowserRequest,
|
||||
qaBrowserAct,
|
||||
qaBrowserOpenTab,
|
||||
qaBrowserSnapshot,
|
||||
waitForQaBrowserReady,
|
||||
} from "./browser-runtime.js";
|
||||
|
||||
function createEnv() {
|
||||
return {
|
||||
gateway: {
|
||||
call: vi.fn(async () => ({ ok: true })),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("browser-runtime", () => {
|
||||
it("sends normalized browser.request payloads through the gateway", async () => {
|
||||
const env = createEnv();
|
||||
|
||||
const result = await callQaBrowserRequest(env, {
|
||||
method: "GET",
|
||||
path: "/snapshot",
|
||||
query: {
|
||||
format: "ai",
|
||||
targetId: "tab-1",
|
||||
skip: undefined,
|
||||
limit: 50,
|
||||
},
|
||||
timeoutMs: 12_345,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ ok: true });
|
||||
expect(env.gateway.call).toHaveBeenCalledWith(
|
||||
"browser.request",
|
||||
{
|
||||
method: "GET",
|
||||
path: "/snapshot",
|
||||
query: {
|
||||
format: "ai",
|
||||
targetId: "tab-1",
|
||||
limit: "50",
|
||||
},
|
||||
body: undefined,
|
||||
timeoutMs: 12_345,
|
||||
},
|
||||
{ timeoutMs: 12_345 },
|
||||
);
|
||||
});
|
||||
|
||||
it("opens tabs through the browser proxy", async () => {
|
||||
const env = createEnv();
|
||||
|
||||
await qaBrowserOpenTab(env, {
|
||||
url: "http://127.0.0.1:43124/control-ui/chat?session=test",
|
||||
profile: "openclaw",
|
||||
});
|
||||
|
||||
expect(env.gateway.call).toHaveBeenCalledWith(
|
||||
"browser.request",
|
||||
{
|
||||
method: "POST",
|
||||
path: "/tabs/open",
|
||||
query: {
|
||||
profile: "openclaw",
|
||||
},
|
||||
body: {
|
||||
url: "http://127.0.0.1:43124/control-ui/chat?session=test",
|
||||
},
|
||||
timeoutMs: 20_000,
|
||||
},
|
||||
{ timeoutMs: 20_000 },
|
||||
);
|
||||
});
|
||||
|
||||
it("captures snapshots with query options", async () => {
|
||||
const env = createEnv();
|
||||
|
||||
await qaBrowserSnapshot(env, {
|
||||
targetId: "tab-1",
|
||||
interactive: true,
|
||||
labels: true,
|
||||
maxChars: 4_000,
|
||||
});
|
||||
|
||||
expect(env.gateway.call).toHaveBeenCalledWith(
|
||||
"browser.request",
|
||||
{
|
||||
method: "GET",
|
||||
path: "/snapshot",
|
||||
query: {
|
||||
targetId: "tab-1",
|
||||
format: "ai",
|
||||
interactive: "true",
|
||||
labels: "true",
|
||||
maxChars: "4000",
|
||||
},
|
||||
body: undefined,
|
||||
timeoutMs: 20_000,
|
||||
},
|
||||
{ timeoutMs: 20_000 },
|
||||
);
|
||||
});
|
||||
|
||||
it("runs browser act requests through /act", async () => {
|
||||
const env = createEnv();
|
||||
|
||||
await qaBrowserAct(env, {
|
||||
profile: "openclaw",
|
||||
request: {
|
||||
kind: "type",
|
||||
ref: "12",
|
||||
text: "hello",
|
||||
submit: true,
|
||||
},
|
||||
timeoutMs: 9_000,
|
||||
});
|
||||
|
||||
expect(env.gateway.call).toHaveBeenCalledWith(
|
||||
"browser.request",
|
||||
{
|
||||
method: "POST",
|
||||
path: "/act",
|
||||
query: {
|
||||
profile: "openclaw",
|
||||
},
|
||||
body: {
|
||||
kind: "type",
|
||||
ref: "12",
|
||||
text: "hello",
|
||||
submit: true,
|
||||
},
|
||||
timeoutMs: 9_000,
|
||||
},
|
||||
{ timeoutMs: 9_000 },
|
||||
);
|
||||
});
|
||||
|
||||
it("waits until browser control reports a ready profile", async () => {
|
||||
const env = createEnv();
|
||||
env.gateway.call = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ enabled: true, running: false, cdpReady: false })
|
||||
.mockResolvedValueOnce({ enabled: true, running: true, cdpReady: true });
|
||||
|
||||
const status = await waitForQaBrowserReady(env, {
|
||||
profile: "user",
|
||||
timeoutMs: 5_000,
|
||||
intervalMs: 1,
|
||||
});
|
||||
|
||||
expect(status).toEqual({ enabled: true, running: true, cdpReady: true });
|
||||
expect(env.gateway.call).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"browser.request",
|
||||
{
|
||||
method: "GET",
|
||||
path: "/",
|
||||
query: {
|
||||
profile: "user",
|
||||
},
|
||||
body: undefined,
|
||||
timeoutMs: 5_000,
|
||||
},
|
||||
{ timeoutMs: 5_000 },
|
||||
);
|
||||
});
|
||||
});
|
||||
212
extensions/qa-lab/src/browser-runtime.ts
Normal file
212
extensions/qa-lab/src/browser-runtime.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
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)})` : ""
|
||||
}`,
|
||||
);
|
||||
}
|
||||
117
extensions/qa-lab/src/web-runtime.test.ts
Normal file
117
extensions/qa-lab/src/web-runtime.test.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const {
|
||||
bodyLocator,
|
||||
browserClose,
|
||||
contextClose,
|
||||
contextNewPage,
|
||||
goto,
|
||||
launch,
|
||||
locatorFill,
|
||||
locatorPress,
|
||||
locatorWaitFor,
|
||||
pageEvaluate,
|
||||
pageTitle,
|
||||
pageUrl,
|
||||
pageWaitForFunction,
|
||||
pageWaitForSelector,
|
||||
} = vi.hoisted(() => ({
|
||||
bodyLocator: {
|
||||
waitFor: vi.fn(async () => undefined),
|
||||
innerText: vi.fn(async () => "hello from body"),
|
||||
},
|
||||
browserClose: vi.fn(async () => undefined),
|
||||
contextClose: vi.fn(async () => undefined),
|
||||
contextNewPage: vi.fn(),
|
||||
goto: vi.fn(async () => undefined),
|
||||
innerText: vi.fn(async () => "hello from body"),
|
||||
launch: vi.fn(),
|
||||
locatorFill: vi.fn(async () => undefined),
|
||||
locatorPress: vi.fn(async () => undefined),
|
||||
locatorWaitFor: vi.fn(async () => undefined),
|
||||
pageEvaluate: vi.fn(async () => "ok"),
|
||||
pageTitle: vi.fn(async () => "QA"),
|
||||
pageUrl: vi.fn(() => "http://127.0.0.1:3000/chat"),
|
||||
pageWaitForFunction: vi.fn(async () => undefined),
|
||||
pageWaitForSelector: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock("playwright-core", () => ({
|
||||
chromium: {
|
||||
launch,
|
||||
},
|
||||
}));
|
||||
|
||||
import {
|
||||
closeAllQaWebSessions,
|
||||
qaWebEvaluate,
|
||||
qaWebOpenPage,
|
||||
qaWebSnapshot,
|
||||
qaWebType,
|
||||
qaWebWait,
|
||||
} from "./web-runtime.js";
|
||||
|
||||
beforeEach(async () => {
|
||||
const page = {
|
||||
goto,
|
||||
title: pageTitle,
|
||||
url: pageUrl,
|
||||
waitForSelector: pageWaitForSelector,
|
||||
waitForFunction: pageWaitForFunction,
|
||||
locator: vi.fn((selector: string) => {
|
||||
if (selector === "body") {
|
||||
return bodyLocator;
|
||||
}
|
||||
return {
|
||||
first: () => ({
|
||||
waitFor: locatorWaitFor,
|
||||
fill: locatorFill,
|
||||
press: locatorPress,
|
||||
}),
|
||||
};
|
||||
}),
|
||||
evaluate: pageEvaluate,
|
||||
};
|
||||
const context = {
|
||||
newPage: vi.fn(async () => page),
|
||||
close: contextClose,
|
||||
};
|
||||
const browser = {
|
||||
newContext: vi.fn(async () => context),
|
||||
close: browserClose,
|
||||
};
|
||||
contextNewPage.mockResolvedValue(page);
|
||||
launch.mockResolvedValue(browser);
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("qa web runtime", () => {
|
||||
it("opens, interacts with, snapshots, and closes a page", async () => {
|
||||
const opened = await qaWebOpenPage({ url: "http://127.0.0.1:3000/chat" });
|
||||
|
||||
await qaWebWait({ pageId: opened.pageId, selector: "textarea" });
|
||||
await qaWebWait({ pageId: opened.pageId, text: "bridge armed" });
|
||||
await qaWebType({
|
||||
pageId: opened.pageId,
|
||||
selector: "textarea",
|
||||
text: "hello",
|
||||
submit: true,
|
||||
});
|
||||
const snapshot = await qaWebSnapshot({ pageId: opened.pageId, maxChars: 5 });
|
||||
const evaluated = await qaWebEvaluate({ pageId: opened.pageId, expression: "'ok'" });
|
||||
await closeAllQaWebSessions();
|
||||
|
||||
expect(launch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ channel: "chrome", headless: true }),
|
||||
);
|
||||
expect(goto).toHaveBeenCalledWith("http://127.0.0.1:3000/chat", expect.any(Object));
|
||||
expect(pageWaitForSelector).toHaveBeenCalledWith("textarea", expect.any(Object));
|
||||
expect(pageWaitForFunction).toHaveBeenCalled();
|
||||
expect(locatorFill).toHaveBeenCalledWith("hello", expect.any(Object));
|
||||
expect(locatorPress).toHaveBeenCalledWith("Enter", expect.any(Object));
|
||||
expect(snapshot.text).toBe("hello");
|
||||
expect(evaluated).toBe("ok");
|
||||
expect(contextClose).toHaveBeenCalledTimes(1);
|
||||
expect(browserClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
154
extensions/qa-lab/src/web-runtime.ts
Normal file
154
extensions/qa-lab/src/web-runtime.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { chromium, type Browser, type BrowserContext, type Page } from "playwright-core";
|
||||
|
||||
type QaWebSession = {
|
||||
browser: Browser;
|
||||
context: BrowserContext;
|
||||
page: Page;
|
||||
};
|
||||
|
||||
type QaWebOpenPageParams = {
|
||||
url: string;
|
||||
headless?: boolean;
|
||||
channel?: "chrome";
|
||||
timeoutMs?: number;
|
||||
viewport?: { width: number; height: number };
|
||||
};
|
||||
|
||||
type QaWebWaitParams = {
|
||||
pageId: string;
|
||||
selector?: string;
|
||||
text?: string;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
type QaWebTypeParams = {
|
||||
pageId: string;
|
||||
selector: string;
|
||||
text: string;
|
||||
submit?: boolean;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
type QaWebSnapshotParams = {
|
||||
pageId: string;
|
||||
timeoutMs?: number;
|
||||
maxChars?: number;
|
||||
};
|
||||
|
||||
type QaWebEvaluateParams = {
|
||||
pageId: string;
|
||||
expression: string;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
const sessions = new Map<string, QaWebSession>();
|
||||
const DEFAULT_WEB_TIMEOUT_MS = 20_000;
|
||||
|
||||
function resolveTimeoutMs(timeoutMs: number | undefined, fallbackMs = DEFAULT_WEB_TIMEOUT_MS) {
|
||||
if (typeof timeoutMs !== "number" || !Number.isFinite(timeoutMs)) {
|
||||
return fallbackMs;
|
||||
}
|
||||
return Math.max(1, Math.floor(timeoutMs));
|
||||
}
|
||||
|
||||
function resolveSession(pageId: string): QaWebSession {
|
||||
const session = sessions.get(pageId);
|
||||
if (!session) {
|
||||
throw new Error(`unknown web session: ${pageId}`);
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
export async function qaWebOpenPage(params: QaWebOpenPageParams) {
|
||||
const timeoutMs = resolveTimeoutMs(params.timeoutMs);
|
||||
const browser = await chromium.launch({
|
||||
channel: params.channel ?? "chrome",
|
||||
headless: params.headless ?? true,
|
||||
});
|
||||
const context = await browser.newContext({
|
||||
ignoreHTTPSErrors: true,
|
||||
viewport: params.viewport ?? { width: 1440, height: 1080 },
|
||||
});
|
||||
const page = await context.newPage();
|
||||
await page.goto(params.url, {
|
||||
waitUntil: "domcontentloaded",
|
||||
timeout: timeoutMs,
|
||||
});
|
||||
const pageId = randomUUID();
|
||||
sessions.set(pageId, { browser, context, page });
|
||||
return {
|
||||
pageId,
|
||||
url: page.url(),
|
||||
title: await page.title().catch(() => ""),
|
||||
};
|
||||
}
|
||||
|
||||
export async function qaWebWait(params: QaWebWaitParams) {
|
||||
const session = resolveSession(params.pageId);
|
||||
const timeoutMs = resolveTimeoutMs(params.timeoutMs);
|
||||
if (params.selector) {
|
||||
await session.page.waitForSelector(params.selector, { timeout: timeoutMs });
|
||||
return { ok: true };
|
||||
}
|
||||
if (params.text) {
|
||||
await session.page.waitForFunction(
|
||||
(expected) => document.body?.innerText?.toLowerCase().includes(expected.toLowerCase()),
|
||||
params.text,
|
||||
{ timeout: timeoutMs },
|
||||
);
|
||||
return { ok: true };
|
||||
}
|
||||
throw new Error("web wait requires selector or text");
|
||||
}
|
||||
|
||||
export async function qaWebType(params: QaWebTypeParams) {
|
||||
const session = resolveSession(params.pageId);
|
||||
const timeoutMs = resolveTimeoutMs(params.timeoutMs);
|
||||
const locator = session.page.locator(params.selector).first();
|
||||
await locator.waitFor({ timeout: timeoutMs });
|
||||
await locator.fill(params.text, { timeout: timeoutMs });
|
||||
if (params.submit) {
|
||||
await locator.press("Enter", { timeout: timeoutMs });
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function qaWebSnapshot(params: QaWebSnapshotParams) {
|
||||
const session = resolveSession(params.pageId);
|
||||
const timeoutMs = resolveTimeoutMs(params.timeoutMs);
|
||||
const body = session.page.locator("body");
|
||||
await body.waitFor({ timeout: timeoutMs });
|
||||
const text = await body.innerText({ timeout: timeoutMs });
|
||||
const maxChars =
|
||||
typeof params.maxChars === "number" && Number.isFinite(params.maxChars)
|
||||
? Math.max(1, Math.floor(params.maxChars))
|
||||
: undefined;
|
||||
return {
|
||||
url: session.page.url(),
|
||||
title: await session.page.title().catch(() => ""),
|
||||
text: maxChars ? text.slice(0, maxChars) : text,
|
||||
};
|
||||
}
|
||||
|
||||
export async function qaWebEvaluate<T = unknown>(params: QaWebEvaluateParams): Promise<T> {
|
||||
const session = resolveSession(params.pageId);
|
||||
const timeoutMs = resolveTimeoutMs(params.timeoutMs);
|
||||
return (await Promise.race([
|
||||
session.page.evaluate(({ expression }) => (0, eval)(expression) as unknown, {
|
||||
expression: params.expression,
|
||||
}),
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error(`web evaluate timed out after ${timeoutMs}ms`)), timeoutMs),
|
||||
),
|
||||
])) as T;
|
||||
}
|
||||
|
||||
export async function closeAllQaWebSessions(): Promise<void> {
|
||||
const active = [...sessions.values()];
|
||||
sessions.clear();
|
||||
for (const session of active) {
|
||||
await session.context.close().catch(() => {});
|
||||
await session.browser.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user