refactor(browser): simplify control API
This commit is contained in:
@@ -1,10 +1,54 @@
|
||||
import type { ScreenshotResult } from "./client.js";
|
||||
import type {
|
||||
BrowserActionOk,
|
||||
BrowserActionPathResult,
|
||||
BrowserActionTabResult,
|
||||
} from "./client-actions-types.js";
|
||||
import { fetchBrowserJson } from "./client-fetch.js";
|
||||
|
||||
export type BrowserActRequest =
|
||||
| {
|
||||
kind: "click";
|
||||
ref: string;
|
||||
targetId?: string;
|
||||
doubleClick?: boolean;
|
||||
button?: string;
|
||||
modifiers?: string[];
|
||||
}
|
||||
| {
|
||||
kind: "type";
|
||||
ref: string;
|
||||
text: string;
|
||||
targetId?: string;
|
||||
submit?: boolean;
|
||||
slowly?: boolean;
|
||||
}
|
||||
| { kind: "press"; key: string; targetId?: string }
|
||||
| { kind: "hover"; ref: string; targetId?: string }
|
||||
| { kind: "drag"; startRef: string; endRef: string; targetId?: string }
|
||||
| { kind: "select"; ref: string; values: string[]; targetId?: string }
|
||||
| {
|
||||
kind: "fill";
|
||||
fields: Array<Record<string, unknown>>;
|
||||
targetId?: string;
|
||||
}
|
||||
| { kind: "resize"; width: number; height: number; targetId?: string }
|
||||
| {
|
||||
kind: "wait";
|
||||
timeMs?: number;
|
||||
text?: string;
|
||||
textGone?: string;
|
||||
targetId?: string;
|
||||
}
|
||||
| { kind: "evaluate"; fn: string; ref?: string; targetId?: string }
|
||||
| { kind: "close"; targetId?: string };
|
||||
|
||||
export type BrowserActResponse = {
|
||||
ok: true;
|
||||
targetId: string;
|
||||
url?: string;
|
||||
result?: unknown;
|
||||
};
|
||||
|
||||
export async function browserNavigate(
|
||||
baseUrl: string,
|
||||
opts: { url: string; targetId?: string },
|
||||
@@ -17,219 +61,63 @@ export async function browserNavigate(
|
||||
});
|
||||
}
|
||||
|
||||
export async function browserResize(
|
||||
baseUrl: string,
|
||||
opts: { width: number; height: number; targetId?: string },
|
||||
): Promise<BrowserActionTabResult> {
|
||||
return await fetchBrowserJson<BrowserActionTabResult>(`${baseUrl}/resize`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
width: opts.width,
|
||||
height: opts.height,
|
||||
targetId: opts.targetId,
|
||||
}),
|
||||
timeoutMs: 20000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function browserClosePage(
|
||||
baseUrl: string,
|
||||
opts: { targetId?: string } = {},
|
||||
): Promise<BrowserActionTabResult> {
|
||||
return await fetchBrowserJson<BrowserActionTabResult>(`${baseUrl}/close`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ targetId: opts.targetId }),
|
||||
timeoutMs: 20000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function browserClick(
|
||||
export async function browserArmDialog(
|
||||
baseUrl: string,
|
||||
opts: {
|
||||
ref: string;
|
||||
accept: boolean;
|
||||
promptText?: string;
|
||||
targetId?: string;
|
||||
doubleClick?: boolean;
|
||||
button?: string;
|
||||
modifiers?: string[];
|
||||
timeoutMs?: number;
|
||||
},
|
||||
): Promise<BrowserActionTabResult> {
|
||||
return await fetchBrowserJson<BrowserActionTabResult>(`${baseUrl}/click`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
ref: opts.ref,
|
||||
targetId: opts.targetId,
|
||||
doubleClick: opts.doubleClick,
|
||||
button: opts.button,
|
||||
modifiers: opts.modifiers,
|
||||
}),
|
||||
timeoutMs: 20000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function browserType(
|
||||
baseUrl: string,
|
||||
opts: {
|
||||
ref: string;
|
||||
text: string;
|
||||
targetId?: string;
|
||||
submit?: boolean;
|
||||
slowly?: boolean;
|
||||
},
|
||||
): Promise<BrowserActionTabResult> {
|
||||
return await fetchBrowserJson<BrowserActionTabResult>(`${baseUrl}/type`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
ref: opts.ref,
|
||||
text: opts.text,
|
||||
targetId: opts.targetId,
|
||||
submit: opts.submit,
|
||||
slowly: opts.slowly,
|
||||
}),
|
||||
timeoutMs: 20000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function browserPressKey(
|
||||
baseUrl: string,
|
||||
opts: { key: string; targetId?: string },
|
||||
): Promise<BrowserActionTabResult> {
|
||||
return await fetchBrowserJson<BrowserActionTabResult>(`${baseUrl}/press`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ key: opts.key, targetId: opts.targetId }),
|
||||
timeoutMs: 20000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function browserHover(
|
||||
baseUrl: string,
|
||||
opts: { ref: string; targetId?: string },
|
||||
): Promise<BrowserActionTabResult> {
|
||||
return await fetchBrowserJson<BrowserActionTabResult>(`${baseUrl}/hover`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ref: opts.ref, targetId: opts.targetId }),
|
||||
timeoutMs: 20000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function browserDrag(
|
||||
baseUrl: string,
|
||||
opts: { startRef: string; endRef: string; targetId?: string },
|
||||
): Promise<BrowserActionTabResult> {
|
||||
return await fetchBrowserJson<BrowserActionTabResult>(`${baseUrl}/drag`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
startRef: opts.startRef,
|
||||
endRef: opts.endRef,
|
||||
targetId: opts.targetId,
|
||||
}),
|
||||
timeoutMs: 20000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function browserSelectOption(
|
||||
baseUrl: string,
|
||||
opts: { ref: string; values: string[]; targetId?: string },
|
||||
): Promise<BrowserActionTabResult> {
|
||||
return await fetchBrowserJson<BrowserActionTabResult>(`${baseUrl}/select`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
ref: opts.ref,
|
||||
values: opts.values,
|
||||
targetId: opts.targetId,
|
||||
}),
|
||||
timeoutMs: 20000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function browserUpload(
|
||||
baseUrl: string,
|
||||
opts: { paths?: string[]; targetId?: string } = {},
|
||||
): Promise<BrowserActionTabResult> {
|
||||
return await fetchBrowserJson<BrowserActionTabResult>(`${baseUrl}/upload`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ paths: opts.paths, targetId: opts.targetId }),
|
||||
timeoutMs: 20000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function browserFillForm(
|
||||
baseUrl: string,
|
||||
opts: { fields: Array<Record<string, unknown>>; targetId?: string },
|
||||
): Promise<BrowserActionTabResult> {
|
||||
return await fetchBrowserJson<BrowserActionTabResult>(`${baseUrl}/fill`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ fields: opts.fields, targetId: opts.targetId }),
|
||||
timeoutMs: 20000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function browserHandleDialog(
|
||||
baseUrl: string,
|
||||
opts: { accept: boolean; promptText?: string; targetId?: string },
|
||||
): Promise<BrowserActionOk> {
|
||||
return await fetchBrowserJson<BrowserActionOk>(`${baseUrl}/dialog`, {
|
||||
return await fetchBrowserJson<BrowserActionOk>(`${baseUrl}/hooks/dialog`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
accept: opts.accept,
|
||||
promptText: opts.promptText,
|
||||
targetId: opts.targetId,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
}),
|
||||
timeoutMs: 20000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function browserWaitFor(
|
||||
export async function browserArmFileChooser(
|
||||
baseUrl: string,
|
||||
opts: {
|
||||
time?: number;
|
||||
text?: string;
|
||||
textGone?: string;
|
||||
paths: string[];
|
||||
targetId?: string;
|
||||
timeoutMs?: number;
|
||||
},
|
||||
): Promise<BrowserActionTabResult> {
|
||||
return await fetchBrowserJson<BrowserActionTabResult>(`${baseUrl}/wait`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
time: opts.time,
|
||||
text: opts.text,
|
||||
textGone: opts.textGone,
|
||||
targetId: opts.targetId,
|
||||
}),
|
||||
timeoutMs: 20000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function browserEvaluate(
|
||||
baseUrl: string,
|
||||
opts: { fn: string; ref?: string; targetId?: string },
|
||||
): Promise<{ ok: true; result: unknown }> {
|
||||
return await fetchBrowserJson<{ ok: true; result: unknown }>(
|
||||
`${baseUrl}/evaluate`,
|
||||
): Promise<BrowserActionOk> {
|
||||
return await fetchBrowserJson<BrowserActionOk>(
|
||||
`${baseUrl}/hooks/file-chooser`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
function: opts.fn,
|
||||
ref: opts.ref,
|
||||
paths: opts.paths,
|
||||
targetId: opts.targetId,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
}),
|
||||
timeoutMs: 20000,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function browserAct(
|
||||
baseUrl: string,
|
||||
req: BrowserActRequest,
|
||||
): Promise<BrowserActResponse> {
|
||||
return await fetchBrowserJson<BrowserActResponse>(`${baseUrl}/act`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(req),
|
||||
timeoutMs: 20000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function browserScreenshotAction(
|
||||
baseUrl: string,
|
||||
opts: {
|
||||
@@ -238,10 +126,9 @@ export async function browserScreenshotAction(
|
||||
ref?: string;
|
||||
element?: string;
|
||||
type?: "png" | "jpeg";
|
||||
filename?: string;
|
||||
},
|
||||
): Promise<ScreenshotResult & { filename?: string }> {
|
||||
return await fetchBrowserJson<ScreenshotResult & { filename?: string }>(
|
||||
): Promise<BrowserActionPathResult> {
|
||||
return await fetchBrowserJson<BrowserActionPathResult>(
|
||||
`${baseUrl}/screenshot`,
|
||||
{
|
||||
method: "POST",
|
||||
@@ -252,7 +139,6 @@ export async function browserScreenshotAction(
|
||||
ref: opts.ref,
|
||||
element: opts.element,
|
||||
type: opts.type,
|
||||
filename: opts.filename,
|
||||
}),
|
||||
timeoutMs: 20000,
|
||||
},
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import type {
|
||||
BrowserActionOk,
|
||||
BrowserActionPathResult,
|
||||
} from "./client-actions-types.js";
|
||||
import type { BrowserActionPathResult } from "./client-actions-types.js";
|
||||
import { fetchBrowserJson } from "./client-fetch.js";
|
||||
import type { BrowserConsoleMessage } from "./pw-session.js";
|
||||
|
||||
@@ -31,64 +28,3 @@ export async function browserPdfSave(
|
||||
timeoutMs: 20000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function browserVerifyElementVisible(
|
||||
baseUrl: string,
|
||||
opts: { role: string; accessibleName: string; targetId?: string },
|
||||
): Promise<BrowserActionOk> {
|
||||
return await fetchBrowserJson<BrowserActionOk>(`${baseUrl}/verify/element`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
role: opts.role,
|
||||
accessibleName: opts.accessibleName,
|
||||
targetId: opts.targetId,
|
||||
}),
|
||||
timeoutMs: 20000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function browserVerifyTextVisible(
|
||||
baseUrl: string,
|
||||
opts: { text: string; targetId?: string },
|
||||
): Promise<BrowserActionOk> {
|
||||
return await fetchBrowserJson<BrowserActionOk>(`${baseUrl}/verify/text`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ text: opts.text, targetId: opts.targetId }),
|
||||
timeoutMs: 20000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function browserVerifyListVisible(
|
||||
baseUrl: string,
|
||||
opts: { ref: string; items: string[]; targetId?: string },
|
||||
): Promise<BrowserActionOk> {
|
||||
return await fetchBrowserJson<BrowserActionOk>(`${baseUrl}/verify/list`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
ref: opts.ref,
|
||||
items: opts.items,
|
||||
targetId: opts.targetId,
|
||||
}),
|
||||
timeoutMs: 20000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function browserVerifyValue(
|
||||
baseUrl: string,
|
||||
opts: { ref: string; type: string; value?: string; targetId?: string },
|
||||
): Promise<BrowserActionOk> {
|
||||
return await fetchBrowserJson<BrowserActionOk>(`${baseUrl}/verify/value`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
ref: opts.ref,
|
||||
type: opts.type,
|
||||
value: opts.value,
|
||||
targetId: opts.targetId,
|
||||
}),
|
||||
timeoutMs: 20000,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -11,5 +11,4 @@ export type BrowserActionPathResult = {
|
||||
path: string;
|
||||
targetId: string;
|
||||
url?: string;
|
||||
filename?: string;
|
||||
};
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
browserClickRef,
|
||||
browserDom,
|
||||
browserOpenTab,
|
||||
browserQuery,
|
||||
browserScreenshot,
|
||||
browserSnapshot,
|
||||
browserStatus,
|
||||
browserTabs,
|
||||
} from "./client.js";
|
||||
import {
|
||||
browserAct,
|
||||
browserArmDialog,
|
||||
browserArmFileChooser,
|
||||
browserConsoleMessages,
|
||||
browserNavigate,
|
||||
browserPdfSave,
|
||||
browserScreenshotAction,
|
||||
} from "./client-actions.js";
|
||||
|
||||
describe("browser client", () => {
|
||||
afterEach(() => {
|
||||
@@ -49,7 +54,7 @@ describe("browser client", () => {
|
||||
);
|
||||
|
||||
await expect(
|
||||
browserDom("http://127.0.0.1:18791", { format: "text", maxChars: 1 }),
|
||||
browserSnapshot("http://127.0.0.1:18791", { format: "aria", limit: 1 }),
|
||||
).rejects.toThrow(/409: conflict/i);
|
||||
});
|
||||
|
||||
@@ -79,7 +84,61 @@ describe("browser client", () => {
|
||||
}),
|
||||
} as unknown as Response;
|
||||
}
|
||||
if (url.includes("/screenshot")) {
|
||||
if (url.endsWith("/navigate")) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
ok: true,
|
||||
targetId: "t1",
|
||||
url: "https://y",
|
||||
}),
|
||||
} as unknown as Response;
|
||||
}
|
||||
if (url.endsWith("/act")) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
ok: true,
|
||||
targetId: "t1",
|
||||
url: "https://x",
|
||||
result: 1,
|
||||
}),
|
||||
} as unknown as Response;
|
||||
}
|
||||
if (url.endsWith("/hooks/file-chooser")) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({ ok: true }),
|
||||
} as unknown as Response;
|
||||
}
|
||||
if (url.endsWith("/hooks/dialog")) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({ ok: true }),
|
||||
} as unknown as Response;
|
||||
}
|
||||
if (url.includes("/console?")) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
ok: true,
|
||||
targetId: "t1",
|
||||
messages: [],
|
||||
}),
|
||||
} as unknown as Response;
|
||||
}
|
||||
if (url.endsWith("/pdf")) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
ok: true,
|
||||
path: "/tmp/a.pdf",
|
||||
targetId: "t1",
|
||||
url: "https://x",
|
||||
}),
|
||||
} as unknown as Response;
|
||||
}
|
||||
if (url.endsWith("/screenshot")) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
@@ -90,29 +149,6 @@ describe("browser client", () => {
|
||||
}),
|
||||
} as unknown as Response;
|
||||
}
|
||||
if (url.includes("/query?")) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
ok: true,
|
||||
targetId: "t1",
|
||||
url: "https://x",
|
||||
matches: [{ index: 0, tag: "a" }],
|
||||
}),
|
||||
} as unknown as Response;
|
||||
}
|
||||
if (url.includes("/dom?")) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
ok: true,
|
||||
targetId: "t1",
|
||||
url: "https://x",
|
||||
format: "text",
|
||||
text: "hi",
|
||||
}),
|
||||
} as unknown as Response;
|
||||
}
|
||||
if (url.includes("/snapshot?")) {
|
||||
return {
|
||||
ok: true,
|
||||
@@ -125,12 +161,6 @@ describe("browser client", () => {
|
||||
}),
|
||||
} as unknown as Response;
|
||||
}
|
||||
if (url.endsWith("/click")) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({ ok: true, targetId: "t1", url: "https://x" }),
|
||||
} as unknown as Response;
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
@@ -163,24 +193,39 @@ describe("browser client", () => {
|
||||
browserOpenTab("http://127.0.0.1:18791", "https://example.com"),
|
||||
).resolves.toMatchObject({ targetId: "t2" });
|
||||
|
||||
await expect(
|
||||
browserScreenshot("http://127.0.0.1:18791", { fullPage: true }),
|
||||
).resolves.toMatchObject({ ok: true, path: "/tmp/a.png" });
|
||||
await expect(
|
||||
browserQuery("http://127.0.0.1:18791", { selector: "a", limit: 1 }),
|
||||
).resolves.toMatchObject({ ok: true });
|
||||
await expect(
|
||||
browserDom("http://127.0.0.1:18791", { format: "text", maxChars: 10 }),
|
||||
).resolves.toMatchObject({ ok: true });
|
||||
await expect(
|
||||
browserSnapshot("http://127.0.0.1:18791", { format: "aria", limit: 1 }),
|
||||
).resolves.toMatchObject({ ok: true, format: "aria" });
|
||||
|
||||
await expect(
|
||||
browserClickRef("http://127.0.0.1:18791", { ref: "1" }),
|
||||
browserNavigate("http://127.0.0.1:18791", { url: "https://example.com" }),
|
||||
).resolves.toMatchObject({ ok: true, targetId: "t1" });
|
||||
await expect(
|
||||
browserAct("http://127.0.0.1:18791", { kind: "click", ref: "1" }),
|
||||
).resolves.toMatchObject({ ok: true, targetId: "t1" });
|
||||
await expect(
|
||||
browserArmFileChooser("http://127.0.0.1:18791", {
|
||||
paths: ["/tmp/a.txt"],
|
||||
}),
|
||||
).resolves.toMatchObject({ ok: true });
|
||||
await expect(
|
||||
browserArmDialog("http://127.0.0.1:18791", { accept: true }),
|
||||
).resolves.toMatchObject({ ok: true });
|
||||
await expect(
|
||||
browserConsoleMessages("http://127.0.0.1:18791", { level: "error" }),
|
||||
).resolves.toMatchObject({ ok: true, targetId: "t1" });
|
||||
await expect(
|
||||
browserPdfSave("http://127.0.0.1:18791"),
|
||||
).resolves.toMatchObject({ ok: true, path: "/tmp/a.pdf" });
|
||||
await expect(
|
||||
browserScreenshotAction("http://127.0.0.1:18791", { fullPage: true }),
|
||||
).resolves.toMatchObject({ ok: true, path: "/tmp/a.png" });
|
||||
|
||||
expect(calls.some((c) => c.url.endsWith("/tabs"))).toBe(true);
|
||||
const open = calls.find((c) => c.url.endsWith("/tabs/open"));
|
||||
expect(open?.init?.method).toBe("POST");
|
||||
|
||||
const screenshot = calls.find((c) => c.url.endsWith("/screenshot"));
|
||||
expect(screenshot?.init?.method).toBe("POST");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,37 +22,6 @@ export type BrowserTab = {
|
||||
type?: string;
|
||||
};
|
||||
|
||||
export type ScreenshotResult = {
|
||||
ok: true;
|
||||
path: string;
|
||||
targetId: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type QueryResult = {
|
||||
ok: true;
|
||||
targetId: string;
|
||||
url: string;
|
||||
matches: Array<{
|
||||
index: number;
|
||||
tag: string;
|
||||
id?: string;
|
||||
className?: string;
|
||||
text?: string;
|
||||
value?: string;
|
||||
href?: string;
|
||||
outerHTML?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type DomResult = {
|
||||
ok: true;
|
||||
targetId: string;
|
||||
url: string;
|
||||
format: "html" | "text";
|
||||
text: string;
|
||||
};
|
||||
|
||||
export type SnapshotAriaNode = {
|
||||
ref: string;
|
||||
role: string;
|
||||
@@ -71,26 +40,6 @@ export type SnapshotResult =
|
||||
url: string;
|
||||
nodes: SnapshotAriaNode[];
|
||||
}
|
||||
| {
|
||||
ok: true;
|
||||
format: "domSnapshot";
|
||||
targetId: string;
|
||||
url: string;
|
||||
nodes: Array<{
|
||||
ref: string;
|
||||
parentRef: string | null;
|
||||
depth: number;
|
||||
tag: string;
|
||||
id?: string;
|
||||
className?: string;
|
||||
role?: string;
|
||||
name?: string;
|
||||
text?: string;
|
||||
href?: string;
|
||||
type?: string;
|
||||
value?: string;
|
||||
}>;
|
||||
}
|
||||
| {
|
||||
ok: true;
|
||||
format: "ai";
|
||||
@@ -168,69 +117,10 @@ export async function browserCloseTab(
|
||||
});
|
||||
}
|
||||
|
||||
export async function browserScreenshot(
|
||||
baseUrl: string,
|
||||
opts: {
|
||||
targetId?: string;
|
||||
fullPage?: boolean;
|
||||
},
|
||||
): Promise<ScreenshotResult> {
|
||||
const q = new URLSearchParams();
|
||||
if (opts.targetId) q.set("targetId", opts.targetId);
|
||||
if (opts.fullPage) q.set("fullPage", "true");
|
||||
const suffix = q.toString() ? `?${q.toString()}` : "";
|
||||
return await fetchBrowserJson<ScreenshotResult>(
|
||||
`${baseUrl}/screenshot${suffix}`,
|
||||
{
|
||||
timeoutMs: 20000,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function browserQuery(
|
||||
baseUrl: string,
|
||||
opts: {
|
||||
selector: string;
|
||||
targetId?: string;
|
||||
limit?: number;
|
||||
},
|
||||
): Promise<QueryResult> {
|
||||
const q = new URLSearchParams();
|
||||
q.set("selector", opts.selector);
|
||||
if (opts.targetId) q.set("targetId", opts.targetId);
|
||||
if (typeof opts.limit === "number") q.set("limit", String(opts.limit));
|
||||
return await fetchBrowserJson<QueryResult>(
|
||||
`${baseUrl}/query?${q.toString()}`,
|
||||
{
|
||||
timeoutMs: 15000,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function browserDom(
|
||||
baseUrl: string,
|
||||
opts: {
|
||||
format: "html" | "text";
|
||||
targetId?: string;
|
||||
maxChars?: number;
|
||||
selector?: string;
|
||||
},
|
||||
): Promise<DomResult> {
|
||||
const q = new URLSearchParams();
|
||||
q.set("format", opts.format);
|
||||
if (opts.targetId) q.set("targetId", opts.targetId);
|
||||
if (typeof opts.maxChars === "number")
|
||||
q.set("maxChars", String(opts.maxChars));
|
||||
if (opts.selector) q.set("selector", opts.selector);
|
||||
return await fetchBrowserJson<DomResult>(`${baseUrl}/dom?${q.toString()}`, {
|
||||
timeoutMs: 20000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function browserSnapshot(
|
||||
baseUrl: string,
|
||||
opts: {
|
||||
format: "aria" | "domSnapshot" | "ai";
|
||||
format: "aria" | "ai";
|
||||
targetId?: string;
|
||||
limit?: number;
|
||||
},
|
||||
@@ -247,25 +137,4 @@ export async function browserSnapshot(
|
||||
);
|
||||
}
|
||||
|
||||
export async function browserClickRef(
|
||||
baseUrl: string,
|
||||
opts: {
|
||||
ref: string;
|
||||
targetId?: string;
|
||||
},
|
||||
): Promise<{ ok: true; targetId: string; url: string }> {
|
||||
return await fetchBrowserJson<{ ok: true; targetId: string; url: string }>(
|
||||
`${baseUrl}/click`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
ref: opts.ref,
|
||||
targetId: opts.targetId,
|
||||
}),
|
||||
timeoutMs: 20000,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Actions beyond the basic read-only commands live in client-actions.ts.
|
||||
|
||||
@@ -100,7 +100,7 @@ describe("pw-ai", () => {
|
||||
).mockResolvedValue(browser);
|
||||
|
||||
const mod = await importModule();
|
||||
await mod.clickRefViaPlaywright({
|
||||
await mod.clickViaPlaywright({
|
||||
cdpPort: 18792,
|
||||
targetId: "T1",
|
||||
ref: "76",
|
||||
@@ -135,7 +135,7 @@ describe("pw-ai", () => {
|
||||
|
||||
const mod = await importModule();
|
||||
await mod.snapshotAiViaPlaywright({ cdpPort: 18792, targetId: "T1" });
|
||||
await mod.clickRefViaPlaywright({
|
||||
await mod.clickViaPlaywright({
|
||||
cdpPort: 18792,
|
||||
targetId: "T1",
|
||||
ref: "1",
|
||||
|
||||
@@ -10,12 +10,12 @@ export {
|
||||
export {
|
||||
armDialogViaPlaywright,
|
||||
armFileUploadViaPlaywright,
|
||||
clickRefViaPlaywright,
|
||||
clickViaPlaywright,
|
||||
closePageViaPlaywright,
|
||||
dragViaPlaywright,
|
||||
evaluateViaPlaywright,
|
||||
fillFormViaPlaywright,
|
||||
getConsoleMessagesViaPlaywright,
|
||||
hoverViaPlaywright,
|
||||
navigateViaPlaywright,
|
||||
pdfViaPlaywright,
|
||||
@@ -27,11 +27,3 @@ export {
|
||||
typeViaPlaywright,
|
||||
waitForViaPlaywright,
|
||||
} from "./pw-tools-core.js";
|
||||
|
||||
export {
|
||||
getConsoleMessagesViaPlaywright,
|
||||
verifyElementVisibleViaPlaywright,
|
||||
verifyListVisibleViaPlaywright,
|
||||
verifyTextVisibleViaPlaywright,
|
||||
verifyValueViaPlaywright,
|
||||
} from "./pw-tools-observe.js";
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
type BrowserConsoleMessage,
|
||||
ensurePageState,
|
||||
getPageForTargetId,
|
||||
refLocator,
|
||||
@@ -36,15 +37,6 @@ export async function snapshotAiViaPlaywright(opts: {
|
||||
return { snapshot: String(result?.full ?? "") };
|
||||
}
|
||||
|
||||
export async function clickRefViaPlaywright(opts: {
|
||||
cdpPort: number;
|
||||
targetId?: string;
|
||||
ref: string;
|
||||
timeoutMs?: number;
|
||||
}): Promise<void> {
|
||||
await clickViaPlaywright(opts);
|
||||
}
|
||||
|
||||
export async function clickViaPlaywright(opts: {
|
||||
cdpPort: number;
|
||||
targetId?: string;
|
||||
@@ -300,15 +292,15 @@ export async function navigateViaPlaywright(opts: {
|
||||
export async function waitForViaPlaywright(opts: {
|
||||
cdpPort: number;
|
||||
targetId?: string;
|
||||
time?: number;
|
||||
timeMs?: number;
|
||||
text?: string;
|
||||
textGone?: string;
|
||||
timeoutMs?: number;
|
||||
}): Promise<void> {
|
||||
const page = await getPageForTargetId(opts);
|
||||
ensurePageState(page);
|
||||
if (typeof opts.time === "number" && Number.isFinite(opts.time)) {
|
||||
await page.waitForTimeout(Math.max(0, opts.time) * 1000);
|
||||
if (typeof opts.timeMs === "number" && Number.isFinite(opts.timeMs)) {
|
||||
await page.waitForTimeout(Math.max(0, opts.timeMs));
|
||||
}
|
||||
if (opts.text) {
|
||||
await page
|
||||
@@ -348,6 +340,13 @@ export async function takeScreenshotViaPlaywright(opts: {
|
||||
const buffer = await locator.screenshot({ type });
|
||||
return { buffer };
|
||||
}
|
||||
if (opts.element) {
|
||||
if (opts.fullPage)
|
||||
throw new Error("fullPage is not supported for element screenshots");
|
||||
const locator = page.locator(opts.element).first();
|
||||
const buffer = await locator.screenshot({ type });
|
||||
return { buffer };
|
||||
}
|
||||
const buffer = await page.screenshot({
|
||||
type,
|
||||
fullPage: Boolean(opts.fullPage),
|
||||
@@ -387,3 +386,31 @@ export async function pdfViaPlaywright(opts: {
|
||||
const buffer = await page.pdf({ printBackground: true });
|
||||
return { buffer };
|
||||
}
|
||||
|
||||
function consolePriority(level: string) {
|
||||
switch (level) {
|
||||
case "error":
|
||||
return 3;
|
||||
case "warning":
|
||||
return 2;
|
||||
case "info":
|
||||
case "log":
|
||||
return 1;
|
||||
case "debug":
|
||||
return 0;
|
||||
default:
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getConsoleMessagesViaPlaywright(opts: {
|
||||
cdpPort: number;
|
||||
targetId?: string;
|
||||
level?: string;
|
||||
}): Promise<BrowserConsoleMessage[]> {
|
||||
const page = await getPageForTargetId(opts);
|
||||
const state = ensurePageState(page);
|
||||
if (!opts.level) return [...state.console];
|
||||
const min = consolePriority(opts.level);
|
||||
return state.console.filter((msg) => consolePriority(msg.type) >= min);
|
||||
}
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
import {
|
||||
type BrowserConsoleMessage,
|
||||
ensurePageState,
|
||||
getPageForTargetId,
|
||||
refLocator,
|
||||
} from "./pw-session.js";
|
||||
|
||||
function consolePriority(level: string) {
|
||||
switch (level) {
|
||||
case "error":
|
||||
return 3;
|
||||
case "warning":
|
||||
return 2;
|
||||
case "info":
|
||||
case "log":
|
||||
return 1;
|
||||
case "debug":
|
||||
return 0;
|
||||
default:
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getConsoleMessagesViaPlaywright(opts: {
|
||||
cdpPort: number;
|
||||
targetId?: string;
|
||||
level?: string;
|
||||
}): Promise<BrowserConsoleMessage[]> {
|
||||
const page = await getPageForTargetId(opts);
|
||||
const state = ensurePageState(page);
|
||||
if (!opts.level) return [...state.console];
|
||||
const min = consolePriority(opts.level);
|
||||
return state.console.filter((msg) => consolePriority(msg.type) >= min);
|
||||
}
|
||||
|
||||
export async function verifyElementVisibleViaPlaywright(opts: {
|
||||
cdpPort: number;
|
||||
targetId?: string;
|
||||
role: string;
|
||||
accessibleName: string;
|
||||
}): Promise<void> {
|
||||
const page = await getPageForTargetId(opts);
|
||||
ensurePageState(page);
|
||||
const locator = page.getByRole(opts.role as never, {
|
||||
name: opts.accessibleName,
|
||||
});
|
||||
if ((await locator.count()) === 0) throw new Error("element not found");
|
||||
if (!(await locator.first().isVisible()))
|
||||
throw new Error("element not visible");
|
||||
}
|
||||
|
||||
export async function verifyTextVisibleViaPlaywright(opts: {
|
||||
cdpPort: number;
|
||||
targetId?: string;
|
||||
text: string;
|
||||
}): Promise<void> {
|
||||
const page = await getPageForTargetId(opts);
|
||||
ensurePageState(page);
|
||||
const locator = page.getByText(opts.text).filter({ visible: true });
|
||||
if ((await locator.count()) === 0) throw new Error("text not found");
|
||||
}
|
||||
|
||||
export async function verifyListVisibleViaPlaywright(opts: {
|
||||
cdpPort: number;
|
||||
targetId?: string;
|
||||
ref: string;
|
||||
items: string[];
|
||||
}): Promise<void> {
|
||||
const page = await getPageForTargetId(opts);
|
||||
ensurePageState(page);
|
||||
const locator = refLocator(page, opts.ref);
|
||||
for (const item of opts.items) {
|
||||
const itemLocator = locator.getByText(item);
|
||||
if ((await itemLocator.count()) === 0)
|
||||
throw new Error(`item "${item}" not found`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function verifyValueViaPlaywright(opts: {
|
||||
cdpPort: number;
|
||||
targetId?: string;
|
||||
ref: string;
|
||||
type: string;
|
||||
value: string;
|
||||
}): Promise<void> {
|
||||
const page = await getPageForTargetId(opts);
|
||||
ensurePageState(page);
|
||||
const locator = refLocator(page, opts.ref);
|
||||
if (opts.type === "checkbox" || opts.type === "radio") {
|
||||
const checked = await locator.isChecked();
|
||||
const expected = opts.value === "true";
|
||||
if (checked !== expected)
|
||||
throw new Error(`expected ${opts.value}, got ${String(checked)}`);
|
||||
return;
|
||||
}
|
||||
const value = await locator.inputValue();
|
||||
if (value !== opts.value)
|
||||
throw new Error(`expected ${opts.value}, got ${value}`);
|
||||
}
|
||||
@@ -1,273 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { BrowserRouteContext } from "../server-context.js";
|
||||
|
||||
const pw = vi.hoisted(() => ({
|
||||
armDialogViaPlaywright: vi.fn().mockResolvedValue(undefined),
|
||||
armFileUploadViaPlaywright: vi.fn().mockResolvedValue(undefined),
|
||||
clickViaPlaywright: vi.fn().mockResolvedValue(undefined),
|
||||
closePageViaPlaywright: vi.fn().mockResolvedValue(undefined),
|
||||
dragViaPlaywright: vi.fn().mockResolvedValue(undefined),
|
||||
evaluateViaPlaywright: vi.fn().mockResolvedValue("result"),
|
||||
fillFormViaPlaywright: vi.fn().mockResolvedValue(undefined),
|
||||
hoverViaPlaywright: vi.fn().mockResolvedValue(undefined),
|
||||
navigateViaPlaywright: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ url: "https://example.com" }),
|
||||
pressKeyViaPlaywright: vi.fn().mockResolvedValue(undefined),
|
||||
resizeViewportViaPlaywright: vi.fn().mockResolvedValue(undefined),
|
||||
selectOptionViaPlaywright: vi.fn().mockResolvedValue(undefined),
|
||||
typeViaPlaywright: vi.fn().mockResolvedValue(undefined),
|
||||
waitForViaPlaywright: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("../pw-ai.js", () => pw);
|
||||
|
||||
import { handleBrowserActionCore } from "./actions-core.js";
|
||||
|
||||
const baseTab = {
|
||||
targetId: "tab1",
|
||||
title: "One",
|
||||
url: "https://example.com",
|
||||
};
|
||||
|
||||
function createRes() {
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: undefined as unknown,
|
||||
status(code: number) {
|
||||
this.statusCode = code;
|
||||
return this;
|
||||
},
|
||||
json(payload: unknown) {
|
||||
this.body = payload;
|
||||
return this;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createCtx(
|
||||
overrides: Partial<BrowserRouteContext> = {},
|
||||
): BrowserRouteContext {
|
||||
return {
|
||||
state: () => {
|
||||
throw new Error("unused");
|
||||
},
|
||||
ensureBrowserAvailable: vi.fn().mockResolvedValue(undefined),
|
||||
ensureTabAvailable: vi.fn().mockResolvedValue(baseTab),
|
||||
isReachable: vi.fn().mockResolvedValue(true),
|
||||
listTabs: vi
|
||||
.fn()
|
||||
.mockResolvedValue([
|
||||
baseTab,
|
||||
{ targetId: "tab2", title: "Two", url: "https://example.com/2" },
|
||||
]),
|
||||
openTab: vi.fn().mockResolvedValue({
|
||||
targetId: "newtab",
|
||||
title: "",
|
||||
url: "about:blank",
|
||||
type: "page",
|
||||
}),
|
||||
focusTab: vi.fn().mockResolvedValue(undefined),
|
||||
closeTab: vi.fn().mockResolvedValue(undefined),
|
||||
stopRunningBrowser: vi.fn().mockResolvedValue({ stopped: true }),
|
||||
mapTabError: vi.fn().mockReturnValue(null),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function callAction(
|
||||
action: Parameters<typeof handleBrowserActionCore>[0]["action"],
|
||||
args: Record<string, unknown> = {},
|
||||
ctxOverride?: Partial<BrowserRouteContext>,
|
||||
) {
|
||||
const res = createRes();
|
||||
const ctx = createCtx(ctxOverride);
|
||||
const handled = await handleBrowserActionCore({
|
||||
action,
|
||||
args,
|
||||
targetId: "",
|
||||
cdpPort: 18792,
|
||||
ctx,
|
||||
res,
|
||||
});
|
||||
return { res, ctx, handled };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("handleBrowserActionCore", () => {
|
||||
it("dispatches core browser actions", async () => {
|
||||
const cases = [
|
||||
{
|
||||
action: "close" as const,
|
||||
args: {},
|
||||
fn: pw.closePageViaPlaywright,
|
||||
expectArgs: { cdpPort: 18792, targetId: "tab1" },
|
||||
expectBody: { ok: true, targetId: "tab1", url: baseTab.url },
|
||||
},
|
||||
{
|
||||
action: "resize" as const,
|
||||
args: { width: 800, height: 600 },
|
||||
fn: pw.resizeViewportViaPlaywright,
|
||||
expectArgs: {
|
||||
cdpPort: 18792,
|
||||
targetId: "tab1",
|
||||
width: 800,
|
||||
height: 600,
|
||||
},
|
||||
expectBody: { ok: true, targetId: "tab1", url: baseTab.url },
|
||||
},
|
||||
{
|
||||
action: "dialog" as const,
|
||||
args: { accept: true, promptText: "ok" },
|
||||
fn: pw.armDialogViaPlaywright,
|
||||
expectArgs: {
|
||||
cdpPort: 18792,
|
||||
targetId: "tab1",
|
||||
accept: true,
|
||||
promptText: "ok",
|
||||
},
|
||||
expectBody: { ok: true },
|
||||
},
|
||||
{
|
||||
action: "evaluate" as const,
|
||||
args: { function: "() => 1", ref: "1" },
|
||||
fn: pw.evaluateViaPlaywright,
|
||||
expectArgs: {
|
||||
cdpPort: 18792,
|
||||
targetId: "tab1",
|
||||
fn: "() => 1",
|
||||
ref: "1",
|
||||
},
|
||||
expectBody: { ok: true, result: "result" },
|
||||
},
|
||||
{
|
||||
action: "upload" as const,
|
||||
args: { paths: ["/tmp/file.txt"] },
|
||||
fn: pw.armFileUploadViaPlaywright,
|
||||
expectArgs: {
|
||||
cdpPort: 18792,
|
||||
targetId: "tab1",
|
||||
paths: ["/tmp/file.txt"],
|
||||
},
|
||||
expectBody: { ok: true, targetId: "tab1" },
|
||||
},
|
||||
{
|
||||
action: "fill" as const,
|
||||
args: { fields: [{ ref: "1", value: "x" }] },
|
||||
fn: pw.fillFormViaPlaywright,
|
||||
expectArgs: {
|
||||
cdpPort: 18792,
|
||||
targetId: "tab1",
|
||||
fields: [{ ref: "1", value: "x" }],
|
||||
},
|
||||
expectBody: { ok: true, targetId: "tab1" },
|
||||
},
|
||||
{
|
||||
action: "press" as const,
|
||||
args: { key: "Enter" },
|
||||
fn: pw.pressKeyViaPlaywright,
|
||||
expectArgs: { cdpPort: 18792, targetId: "tab1", key: "Enter" },
|
||||
expectBody: { ok: true, targetId: "tab1" },
|
||||
},
|
||||
{
|
||||
action: "type" as const,
|
||||
args: { ref: "2", text: "hi", submit: true, slowly: true },
|
||||
fn: pw.typeViaPlaywright,
|
||||
expectArgs: {
|
||||
cdpPort: 18792,
|
||||
targetId: "tab1",
|
||||
ref: "2",
|
||||
text: "hi",
|
||||
submit: true,
|
||||
slowly: true,
|
||||
},
|
||||
expectBody: { ok: true, targetId: "tab1" },
|
||||
},
|
||||
{
|
||||
action: "navigate" as const,
|
||||
args: { url: "https://example.com" },
|
||||
fn: pw.navigateViaPlaywright,
|
||||
expectArgs: {
|
||||
cdpPort: 18792,
|
||||
targetId: "tab1",
|
||||
url: "https://example.com",
|
||||
},
|
||||
expectBody: { ok: true, targetId: "tab1", url: baseTab.url },
|
||||
},
|
||||
{
|
||||
action: "click" as const,
|
||||
args: {
|
||||
ref: "1",
|
||||
doubleClick: true,
|
||||
button: "right",
|
||||
modifiers: ["Shift"],
|
||||
},
|
||||
fn: pw.clickViaPlaywright,
|
||||
expectArgs: {
|
||||
cdpPort: 18792,
|
||||
targetId: "tab1",
|
||||
ref: "1",
|
||||
doubleClick: true,
|
||||
button: "right",
|
||||
modifiers: ["Shift"],
|
||||
},
|
||||
expectBody: { ok: true, targetId: "tab1", url: baseTab.url },
|
||||
},
|
||||
{
|
||||
action: "drag" as const,
|
||||
args: { startRef: "1", endRef: "2" },
|
||||
fn: pw.dragViaPlaywright,
|
||||
expectArgs: {
|
||||
cdpPort: 18792,
|
||||
targetId: "tab1",
|
||||
startRef: "1",
|
||||
endRef: "2",
|
||||
},
|
||||
expectBody: { ok: true, targetId: "tab1" },
|
||||
},
|
||||
{
|
||||
action: "hover" as const,
|
||||
args: { ref: "3" },
|
||||
fn: pw.hoverViaPlaywright,
|
||||
expectArgs: { cdpPort: 18792, targetId: "tab1", ref: "3" },
|
||||
expectBody: { ok: true, targetId: "tab1" },
|
||||
},
|
||||
{
|
||||
action: "select" as const,
|
||||
args: { ref: "4", values: ["A"] },
|
||||
fn: pw.selectOptionViaPlaywright,
|
||||
expectArgs: {
|
||||
cdpPort: 18792,
|
||||
targetId: "tab1",
|
||||
ref: "4",
|
||||
values: ["A"],
|
||||
},
|
||||
expectBody: { ok: true, targetId: "tab1" },
|
||||
},
|
||||
{
|
||||
action: "wait" as const,
|
||||
args: { time: 500, text: "ok", textGone: "bye" },
|
||||
fn: pw.waitForViaPlaywright,
|
||||
expectArgs: {
|
||||
cdpPort: 18792,
|
||||
targetId: "tab1",
|
||||
time: 500,
|
||||
text: "ok",
|
||||
textGone: "bye",
|
||||
},
|
||||
expectBody: { ok: true, targetId: "tab1" },
|
||||
},
|
||||
];
|
||||
|
||||
for (const item of cases) {
|
||||
const { res, handled } = await callAction(item.action, item.args);
|
||||
expect(handled).toBe(true);
|
||||
expect(item.fn).toHaveBeenCalledWith(item.expectArgs);
|
||||
expect(res.body).toEqual(item.expectBody);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,307 +0,0 @@
|
||||
import type express from "express";
|
||||
|
||||
import {
|
||||
armDialogViaPlaywright,
|
||||
armFileUploadViaPlaywright,
|
||||
clickViaPlaywright,
|
||||
closePageViaPlaywright,
|
||||
dragViaPlaywright,
|
||||
evaluateViaPlaywright,
|
||||
fillFormViaPlaywright,
|
||||
hoverViaPlaywright,
|
||||
navigateViaPlaywright,
|
||||
pressKeyViaPlaywright,
|
||||
resizeViewportViaPlaywright,
|
||||
selectOptionViaPlaywright,
|
||||
typeViaPlaywright,
|
||||
waitForViaPlaywright,
|
||||
} from "../pw-ai.js";
|
||||
import type { BrowserRouteContext } from "../server-context.js";
|
||||
import {
|
||||
jsonError,
|
||||
toBoolean,
|
||||
toNumber,
|
||||
toStringArray,
|
||||
toStringOrEmpty,
|
||||
} from "./utils.js";
|
||||
|
||||
type MouseButton = "left" | "right" | "middle";
|
||||
type KeyboardModifier = "Alt" | "Control" | "ControlOrMeta" | "Meta" | "Shift";
|
||||
|
||||
function normalizeMouseButton(value: unknown): MouseButton | undefined {
|
||||
const raw = toStringOrEmpty(value);
|
||||
if (raw === "left" || raw === "right" || raw === "middle") return raw;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function normalizeModifiers(value: unknown): KeyboardModifier[] | undefined {
|
||||
const raw = toStringArray(value);
|
||||
if (!raw?.length) return undefined;
|
||||
const normalized = raw.filter(
|
||||
(m): m is KeyboardModifier =>
|
||||
m === "Alt" ||
|
||||
m === "Control" ||
|
||||
m === "ControlOrMeta" ||
|
||||
m === "Meta" ||
|
||||
m === "Shift",
|
||||
);
|
||||
return normalized.length ? normalized : undefined;
|
||||
}
|
||||
|
||||
export type BrowserActionCore =
|
||||
| "click"
|
||||
| "close"
|
||||
| "dialog"
|
||||
| "drag"
|
||||
| "evaluate"
|
||||
| "fill"
|
||||
| "hover"
|
||||
| "navigate"
|
||||
| "press"
|
||||
| "resize"
|
||||
| "select"
|
||||
| "type"
|
||||
| "upload"
|
||||
| "wait";
|
||||
|
||||
type ActionCoreParams = {
|
||||
action: BrowserActionCore;
|
||||
args: Record<string, unknown>;
|
||||
targetId: string;
|
||||
cdpPort: number;
|
||||
ctx: BrowserRouteContext;
|
||||
res: express.Response;
|
||||
};
|
||||
|
||||
export async function handleBrowserActionCore(
|
||||
params: ActionCoreParams,
|
||||
): Promise<boolean> {
|
||||
const { action, args, targetId, cdpPort, ctx, res } = params;
|
||||
const target = targetId || undefined;
|
||||
|
||||
switch (action) {
|
||||
case "close": {
|
||||
const tab = await ctx.ensureTabAvailable(target);
|
||||
await closePageViaPlaywright({ cdpPort, targetId: tab.targetId });
|
||||
res.json({ ok: true, targetId: tab.targetId, url: tab.url });
|
||||
return true;
|
||||
}
|
||||
case "resize": {
|
||||
const width = toNumber(args.width);
|
||||
const height = toNumber(args.height);
|
||||
if (!width || !height) {
|
||||
jsonError(res, 400, "width and height are required");
|
||||
return true;
|
||||
}
|
||||
const tab = await ctx.ensureTabAvailable(target);
|
||||
await resizeViewportViaPlaywright({
|
||||
cdpPort,
|
||||
targetId: tab.targetId,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
res.json({ ok: true, targetId: tab.targetId, url: tab.url });
|
||||
return true;
|
||||
}
|
||||
case "dialog": {
|
||||
const accept = toBoolean(args.accept);
|
||||
if (accept === undefined) {
|
||||
jsonError(res, 400, "accept is required");
|
||||
return true;
|
||||
}
|
||||
const promptText = toStringOrEmpty(args.promptText) || undefined;
|
||||
const tab = await ctx.ensureTabAvailable(target);
|
||||
await armDialogViaPlaywright({
|
||||
cdpPort,
|
||||
targetId: tab.targetId,
|
||||
accept,
|
||||
promptText,
|
||||
});
|
||||
res.json({ ok: true });
|
||||
return true;
|
||||
}
|
||||
case "evaluate": {
|
||||
const fn = toStringOrEmpty(args.function);
|
||||
if (!fn) {
|
||||
jsonError(res, 400, "function is required");
|
||||
return true;
|
||||
}
|
||||
const ref = toStringOrEmpty(args.ref) || undefined;
|
||||
const tab = await ctx.ensureTabAvailable(target);
|
||||
const result = await evaluateViaPlaywright({
|
||||
cdpPort,
|
||||
targetId: tab.targetId,
|
||||
fn,
|
||||
ref,
|
||||
});
|
||||
res.json({ ok: true, result });
|
||||
return true;
|
||||
}
|
||||
case "upload": {
|
||||
const paths = toStringArray(args.paths) ?? [];
|
||||
const tab = await ctx.ensureTabAvailable(target);
|
||||
await armFileUploadViaPlaywright({
|
||||
cdpPort,
|
||||
targetId: tab.targetId,
|
||||
paths: paths.length ? paths : undefined,
|
||||
});
|
||||
res.json({ ok: true, targetId: tab.targetId });
|
||||
return true;
|
||||
}
|
||||
case "fill": {
|
||||
const fields = Array.isArray(args.fields)
|
||||
? (args.fields as Array<Record<string, unknown>>)
|
||||
: null;
|
||||
if (!fields?.length) {
|
||||
jsonError(res, 400, "fields are required");
|
||||
return true;
|
||||
}
|
||||
const tab = await ctx.ensureTabAvailable(target);
|
||||
await fillFormViaPlaywright({
|
||||
cdpPort,
|
||||
targetId: tab.targetId,
|
||||
fields,
|
||||
});
|
||||
res.json({ ok: true, targetId: tab.targetId });
|
||||
return true;
|
||||
}
|
||||
case "press": {
|
||||
const key = toStringOrEmpty(args.key);
|
||||
if (!key) {
|
||||
jsonError(res, 400, "key is required");
|
||||
return true;
|
||||
}
|
||||
const tab = await ctx.ensureTabAvailable(target);
|
||||
await pressKeyViaPlaywright({
|
||||
cdpPort,
|
||||
targetId: tab.targetId,
|
||||
key,
|
||||
});
|
||||
res.json({ ok: true, targetId: tab.targetId });
|
||||
return true;
|
||||
}
|
||||
case "type": {
|
||||
const ref = toStringOrEmpty(args.ref);
|
||||
const text = toStringOrEmpty(args.text);
|
||||
if (!ref || !text) {
|
||||
jsonError(res, 400, "ref and text are required");
|
||||
return true;
|
||||
}
|
||||
const submit = toBoolean(args.submit) ?? false;
|
||||
const slowly = toBoolean(args.slowly) ?? false;
|
||||
const tab = await ctx.ensureTabAvailable(target);
|
||||
await typeViaPlaywright({
|
||||
cdpPort,
|
||||
targetId: tab.targetId,
|
||||
ref,
|
||||
text,
|
||||
submit,
|
||||
slowly,
|
||||
});
|
||||
res.json({ ok: true, targetId: tab.targetId });
|
||||
return true;
|
||||
}
|
||||
case "navigate": {
|
||||
const url = toStringOrEmpty(args.url);
|
||||
if (!url) {
|
||||
jsonError(res, 400, "url is required");
|
||||
return true;
|
||||
}
|
||||
const tab = await ctx.ensureTabAvailable(target);
|
||||
const result = await navigateViaPlaywright({
|
||||
cdpPort,
|
||||
targetId: tab.targetId,
|
||||
url,
|
||||
});
|
||||
res.json({ ok: true, targetId: tab.targetId, ...result });
|
||||
return true;
|
||||
}
|
||||
case "click": {
|
||||
const ref = toStringOrEmpty(args.ref);
|
||||
if (!ref) {
|
||||
jsonError(res, 400, "ref is required");
|
||||
return true;
|
||||
}
|
||||
const doubleClick = toBoolean(args.doubleClick) ?? false;
|
||||
const button = normalizeMouseButton(args.button);
|
||||
const modifiers = normalizeModifiers(args.modifiers);
|
||||
const tab = await ctx.ensureTabAvailable(target);
|
||||
await clickViaPlaywright({
|
||||
cdpPort,
|
||||
targetId: tab.targetId,
|
||||
ref,
|
||||
doubleClick,
|
||||
button,
|
||||
modifiers,
|
||||
});
|
||||
res.json({ ok: true, targetId: tab.targetId, url: tab.url });
|
||||
return true;
|
||||
}
|
||||
case "drag": {
|
||||
const startRef = toStringOrEmpty(args.startRef);
|
||||
const endRef = toStringOrEmpty(args.endRef);
|
||||
if (!startRef || !endRef) {
|
||||
jsonError(res, 400, "startRef and endRef are required");
|
||||
return true;
|
||||
}
|
||||
const tab = await ctx.ensureTabAvailable(target);
|
||||
await dragViaPlaywright({
|
||||
cdpPort,
|
||||
targetId: tab.targetId,
|
||||
startRef,
|
||||
endRef,
|
||||
});
|
||||
res.json({ ok: true, targetId: tab.targetId });
|
||||
return true;
|
||||
}
|
||||
case "hover": {
|
||||
const ref = toStringOrEmpty(args.ref);
|
||||
if (!ref) {
|
||||
jsonError(res, 400, "ref is required");
|
||||
return true;
|
||||
}
|
||||
const tab = await ctx.ensureTabAvailable(target);
|
||||
await hoverViaPlaywright({
|
||||
cdpPort,
|
||||
targetId: tab.targetId,
|
||||
ref,
|
||||
});
|
||||
res.json({ ok: true, targetId: tab.targetId });
|
||||
return true;
|
||||
}
|
||||
case "select": {
|
||||
const ref = toStringOrEmpty(args.ref);
|
||||
const values = toStringArray(args.values);
|
||||
if (!ref || !values?.length) {
|
||||
jsonError(res, 400, "ref and values are required");
|
||||
return true;
|
||||
}
|
||||
const tab = await ctx.ensureTabAvailable(target);
|
||||
await selectOptionViaPlaywright({
|
||||
cdpPort,
|
||||
targetId: tab.targetId,
|
||||
ref,
|
||||
values,
|
||||
});
|
||||
res.json({ ok: true, targetId: tab.targetId });
|
||||
return true;
|
||||
}
|
||||
case "wait": {
|
||||
const time = toNumber(args.time);
|
||||
const text = toStringOrEmpty(args.text) || undefined;
|
||||
const textGone = toStringOrEmpty(args.textGone) || undefined;
|
||||
const tab = await ctx.ensureTabAvailable(target);
|
||||
await waitForViaPlaywright({
|
||||
cdpPort,
|
||||
targetId: tab.targetId,
|
||||
time,
|
||||
text,
|
||||
textGone,
|
||||
});
|
||||
res.json({ ok: true, targetId: tab.targetId });
|
||||
return true;
|
||||
}
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { BrowserRouteContext } from "../server-context.js";
|
||||
|
||||
const pw = vi.hoisted(() => ({
|
||||
getConsoleMessagesViaPlaywright: vi.fn().mockResolvedValue([]),
|
||||
pdfViaPlaywright: vi.fn().mockResolvedValue({ buffer: Buffer.from("pdf") }),
|
||||
verifyElementVisibleViaPlaywright: vi.fn().mockResolvedValue(undefined),
|
||||
verifyListVisibleViaPlaywright: vi.fn().mockResolvedValue(undefined),
|
||||
verifyTextVisibleViaPlaywright: vi.fn().mockResolvedValue(undefined),
|
||||
verifyValueViaPlaywright: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const media = vi.hoisted(() => ({
|
||||
ensureMediaDir: vi.fn().mockResolvedValue(undefined),
|
||||
saveMediaBuffer: vi.fn().mockResolvedValue({ path: "/tmp/fake.pdf" }),
|
||||
}));
|
||||
|
||||
vi.mock("../pw-ai.js", () => pw);
|
||||
vi.mock("../../media/store.js", () => media);
|
||||
|
||||
import { handleBrowserActionExtra } from "./actions-extra.js";
|
||||
|
||||
const baseTab = {
|
||||
targetId: "tab1",
|
||||
title: "One",
|
||||
url: "https://example.com",
|
||||
};
|
||||
|
||||
function createRes() {
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: undefined as unknown,
|
||||
status(code: number) {
|
||||
this.statusCode = code;
|
||||
return this;
|
||||
},
|
||||
json(payload: unknown) {
|
||||
this.body = payload;
|
||||
return this;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createCtx(
|
||||
overrides: Partial<BrowserRouteContext> = {},
|
||||
): BrowserRouteContext {
|
||||
return {
|
||||
state: () => {
|
||||
throw new Error("unused");
|
||||
},
|
||||
ensureBrowserAvailable: vi.fn().mockResolvedValue(undefined),
|
||||
ensureTabAvailable: vi.fn().mockResolvedValue(baseTab),
|
||||
isReachable: vi.fn().mockResolvedValue(true),
|
||||
listTabs: vi.fn().mockResolvedValue([baseTab]),
|
||||
openTab: vi.fn().mockResolvedValue({
|
||||
targetId: "newtab",
|
||||
title: "",
|
||||
url: "about:blank",
|
||||
type: "page",
|
||||
}),
|
||||
focusTab: vi.fn().mockResolvedValue(undefined),
|
||||
closeTab: vi.fn().mockResolvedValue(undefined),
|
||||
stopRunningBrowser: vi.fn().mockResolvedValue({ stopped: true }),
|
||||
mapTabError: vi.fn().mockReturnValue(null),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function callAction(
|
||||
action: Parameters<typeof handleBrowserActionExtra>[0]["action"],
|
||||
args: Record<string, unknown> = {},
|
||||
) {
|
||||
const res = createRes();
|
||||
const ctx = createCtx();
|
||||
const handled = await handleBrowserActionExtra({
|
||||
action,
|
||||
args,
|
||||
targetId: "",
|
||||
cdpPort: 18792,
|
||||
ctx,
|
||||
res,
|
||||
});
|
||||
return { res, ctx, handled };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("handleBrowserActionExtra", () => {
|
||||
it("dispatches extra browser actions", async () => {
|
||||
const cases = [
|
||||
{
|
||||
action: "console" as const,
|
||||
args: { level: "error" },
|
||||
fn: pw.getConsoleMessagesViaPlaywright,
|
||||
expectArgs: {
|
||||
cdpPort: 18792,
|
||||
targetId: "tab1",
|
||||
level: "error",
|
||||
},
|
||||
expectBody: { ok: true, messages: [], targetId: "tab1" },
|
||||
},
|
||||
{
|
||||
action: "verifyElement" as const,
|
||||
args: { role: "button", accessibleName: "Submit" },
|
||||
fn: pw.verifyElementVisibleViaPlaywright,
|
||||
expectArgs: {
|
||||
cdpPort: 18792,
|
||||
targetId: "tab1",
|
||||
role: "button",
|
||||
accessibleName: "Submit",
|
||||
},
|
||||
expectBody: { ok: true },
|
||||
},
|
||||
{
|
||||
action: "verifyText" as const,
|
||||
args: { text: "Hello" },
|
||||
fn: pw.verifyTextVisibleViaPlaywright,
|
||||
expectArgs: { cdpPort: 18792, targetId: "tab1", text: "Hello" },
|
||||
expectBody: { ok: true },
|
||||
},
|
||||
{
|
||||
action: "verifyList" as const,
|
||||
args: { ref: "1", items: ["a", "b"] },
|
||||
fn: pw.verifyListVisibleViaPlaywright,
|
||||
expectArgs: {
|
||||
cdpPort: 18792,
|
||||
targetId: "tab1",
|
||||
ref: "1",
|
||||
items: ["a", "b"],
|
||||
},
|
||||
expectBody: { ok: true },
|
||||
},
|
||||
{
|
||||
action: "verifyValue" as const,
|
||||
args: { ref: "2", type: "textbox", value: "x" },
|
||||
fn: pw.verifyValueViaPlaywright,
|
||||
expectArgs: {
|
||||
cdpPort: 18792,
|
||||
targetId: "tab1",
|
||||
ref: "2",
|
||||
type: "textbox",
|
||||
value: "x",
|
||||
},
|
||||
expectBody: { ok: true },
|
||||
},
|
||||
];
|
||||
|
||||
for (const item of cases) {
|
||||
const { res, handled } = await callAction(item.action, item.args);
|
||||
expect(handled).toBe(true);
|
||||
expect(item.fn).toHaveBeenCalledWith(item.expectArgs);
|
||||
expect(res.body).toEqual(item.expectBody);
|
||||
}
|
||||
});
|
||||
|
||||
it("stores PDF output", async () => {
|
||||
const { res: pdfRes } = await callAction("pdf");
|
||||
expect(pw.pdfViaPlaywright).toHaveBeenCalledWith({
|
||||
cdpPort: 18792,
|
||||
targetId: "tab1",
|
||||
});
|
||||
expect(media.ensureMediaDir).toHaveBeenCalled();
|
||||
expect(media.saveMediaBuffer).toHaveBeenCalled();
|
||||
expect(pdfRes.body).toMatchObject({
|
||||
ok: true,
|
||||
path: "/tmp/fake.pdf",
|
||||
targetId: "tab1",
|
||||
url: baseTab.url,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,144 +0,0 @@
|
||||
import path from "node:path";
|
||||
|
||||
import type express from "express";
|
||||
|
||||
import { ensureMediaDir, saveMediaBuffer } from "../../media/store.js";
|
||||
import {
|
||||
getConsoleMessagesViaPlaywright,
|
||||
pdfViaPlaywright,
|
||||
verifyElementVisibleViaPlaywright,
|
||||
verifyListVisibleViaPlaywright,
|
||||
verifyTextVisibleViaPlaywright,
|
||||
verifyValueViaPlaywright,
|
||||
} from "../pw-ai.js";
|
||||
import type { BrowserRouteContext } from "../server-context.js";
|
||||
import { jsonError, toStringArray, toStringOrEmpty } from "./utils.js";
|
||||
|
||||
export type BrowserActionExtra =
|
||||
| "console"
|
||||
| "pdf"
|
||||
| "verifyElement"
|
||||
| "verifyList"
|
||||
| "verifyText"
|
||||
| "verifyValue";
|
||||
|
||||
type ActionExtraParams = {
|
||||
action: BrowserActionExtra;
|
||||
args: Record<string, unknown>;
|
||||
targetId: string;
|
||||
cdpPort: number;
|
||||
ctx: BrowserRouteContext;
|
||||
res: express.Response;
|
||||
};
|
||||
|
||||
export async function handleBrowserActionExtra(
|
||||
params: ActionExtraParams,
|
||||
): Promise<boolean> {
|
||||
const { action, args, targetId, cdpPort, ctx, res } = params;
|
||||
const target = targetId || undefined;
|
||||
|
||||
switch (action) {
|
||||
case "console": {
|
||||
const level = toStringOrEmpty(args.level) || undefined;
|
||||
const tab = await ctx.ensureTabAvailable(target);
|
||||
const messages = await getConsoleMessagesViaPlaywright({
|
||||
cdpPort,
|
||||
targetId: tab.targetId,
|
||||
level,
|
||||
});
|
||||
res.json({ ok: true, messages, targetId: tab.targetId });
|
||||
return true;
|
||||
}
|
||||
case "pdf": {
|
||||
const tab = await ctx.ensureTabAvailable(target);
|
||||
const pdf = await pdfViaPlaywright({
|
||||
cdpPort,
|
||||
targetId: tab.targetId,
|
||||
});
|
||||
await ensureMediaDir();
|
||||
const saved = await saveMediaBuffer(
|
||||
pdf.buffer,
|
||||
"application/pdf",
|
||||
"browser",
|
||||
pdf.buffer.byteLength,
|
||||
);
|
||||
res.json({
|
||||
ok: true,
|
||||
path: path.resolve(saved.path),
|
||||
targetId: tab.targetId,
|
||||
url: tab.url,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
case "verifyElement": {
|
||||
const role = toStringOrEmpty(args.role);
|
||||
const accessibleName = toStringOrEmpty(args.accessibleName);
|
||||
if (!role || !accessibleName) {
|
||||
jsonError(res, 400, "role and accessibleName are required");
|
||||
return true;
|
||||
}
|
||||
const tab = await ctx.ensureTabAvailable(target);
|
||||
await verifyElementVisibleViaPlaywright({
|
||||
cdpPort,
|
||||
targetId: tab.targetId,
|
||||
role,
|
||||
accessibleName,
|
||||
});
|
||||
res.json({ ok: true });
|
||||
return true;
|
||||
}
|
||||
case "verifyText": {
|
||||
const text = toStringOrEmpty(args.text);
|
||||
if (!text) {
|
||||
jsonError(res, 400, "text is required");
|
||||
return true;
|
||||
}
|
||||
const tab = await ctx.ensureTabAvailable(target);
|
||||
await verifyTextVisibleViaPlaywright({
|
||||
cdpPort,
|
||||
targetId: tab.targetId,
|
||||
text,
|
||||
});
|
||||
res.json({ ok: true });
|
||||
return true;
|
||||
}
|
||||
case "verifyList": {
|
||||
const ref = toStringOrEmpty(args.ref);
|
||||
const items = toStringArray(args.items);
|
||||
if (!ref || !items?.length) {
|
||||
jsonError(res, 400, "ref and items are required");
|
||||
return true;
|
||||
}
|
||||
const tab = await ctx.ensureTabAvailable(target);
|
||||
await verifyListVisibleViaPlaywright({
|
||||
cdpPort,
|
||||
targetId: tab.targetId,
|
||||
ref,
|
||||
items,
|
||||
});
|
||||
res.json({ ok: true });
|
||||
return true;
|
||||
}
|
||||
case "verifyValue": {
|
||||
const ref = toStringOrEmpty(args.ref);
|
||||
const type = toStringOrEmpty(args.type);
|
||||
const value = toStringOrEmpty(args.value);
|
||||
if (!ref || !type) {
|
||||
jsonError(res, 400, "ref and type are required");
|
||||
return true;
|
||||
}
|
||||
const tab = await ctx.ensureTabAvailable(target);
|
||||
await verifyValueViaPlaywright({
|
||||
cdpPort,
|
||||
targetId: tab.targetId,
|
||||
ref,
|
||||
type,
|
||||
value,
|
||||
});
|
||||
res.json({ ok: true });
|
||||
return true;
|
||||
}
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
import type express from "express";
|
||||
|
||||
import type { BrowserRouteContext } from "../server-context.js";
|
||||
import { handleBrowserActionCore } from "./actions-core.js";
|
||||
import { handleBrowserActionExtra } from "./actions-extra.js";
|
||||
import { jsonError, toStringOrEmpty } from "./utils.js";
|
||||
|
||||
function readBody(req: express.Request): Record<string, unknown> {
|
||||
const body = req.body as Record<string, unknown> | undefined;
|
||||
if (!body || typeof body !== "object" || Array.isArray(body)) return {};
|
||||
return body;
|
||||
}
|
||||
|
||||
function readTargetId(value: unknown): string {
|
||||
return toStringOrEmpty(value);
|
||||
}
|
||||
|
||||
function handleActionError(
|
||||
ctx: BrowserRouteContext,
|
||||
res: express.Response,
|
||||
err: unknown,
|
||||
) {
|
||||
const mapped = ctx.mapTabError(err);
|
||||
if (mapped) return jsonError(res, mapped.status, mapped.message);
|
||||
jsonError(res, 500, String(err));
|
||||
}
|
||||
|
||||
async function runCoreAction(
|
||||
ctx: BrowserRouteContext,
|
||||
res: express.Response,
|
||||
action: Parameters<typeof handleBrowserActionCore>[0]["action"],
|
||||
args: Record<string, unknown>,
|
||||
targetId: string,
|
||||
) {
|
||||
try {
|
||||
const cdpPort = ctx.state().cdpPort;
|
||||
await handleBrowserActionCore({
|
||||
action,
|
||||
args,
|
||||
targetId,
|
||||
cdpPort,
|
||||
ctx,
|
||||
res,
|
||||
});
|
||||
} catch (err) {
|
||||
handleActionError(ctx, res, err);
|
||||
}
|
||||
}
|
||||
|
||||
async function runExtraAction(
|
||||
ctx: BrowserRouteContext,
|
||||
res: express.Response,
|
||||
action: Parameters<typeof handleBrowserActionExtra>[0]["action"],
|
||||
args: Record<string, unknown>,
|
||||
targetId: string,
|
||||
) {
|
||||
try {
|
||||
const cdpPort = ctx.state().cdpPort;
|
||||
await handleBrowserActionExtra({
|
||||
action,
|
||||
args,
|
||||
targetId,
|
||||
cdpPort,
|
||||
ctx,
|
||||
res,
|
||||
});
|
||||
} catch (err) {
|
||||
handleActionError(ctx, res, err);
|
||||
}
|
||||
}
|
||||
|
||||
export function registerBrowserActionRoutes(
|
||||
app: express.Express,
|
||||
ctx: BrowserRouteContext,
|
||||
) {
|
||||
app.post("/navigate", async (req, res) => {
|
||||
const body = readBody(req);
|
||||
const targetId = readTargetId(body.targetId);
|
||||
await runCoreAction(ctx, res, "navigate", body, targetId);
|
||||
});
|
||||
|
||||
app.post("/resize", async (req, res) => {
|
||||
const body = readBody(req);
|
||||
const targetId = readTargetId(body.targetId);
|
||||
await runCoreAction(ctx, res, "resize", body, targetId);
|
||||
});
|
||||
|
||||
app.post("/close", async (req, res) => {
|
||||
const body = readBody(req);
|
||||
const targetId = readTargetId(body.targetId);
|
||||
await runCoreAction(ctx, res, "close", body, targetId);
|
||||
});
|
||||
|
||||
app.post("/click", async (req, res) => {
|
||||
const body = readBody(req);
|
||||
const targetId = readTargetId(body.targetId);
|
||||
await runCoreAction(ctx, res, "click", body, targetId);
|
||||
});
|
||||
|
||||
app.post("/type", async (req, res) => {
|
||||
const body = readBody(req);
|
||||
const targetId = readTargetId(body.targetId);
|
||||
await runCoreAction(ctx, res, "type", body, targetId);
|
||||
});
|
||||
|
||||
app.post("/press", async (req, res) => {
|
||||
const body = readBody(req);
|
||||
const targetId = readTargetId(body.targetId);
|
||||
await runCoreAction(ctx, res, "press", body, targetId);
|
||||
});
|
||||
|
||||
app.post("/hover", async (req, res) => {
|
||||
const body = readBody(req);
|
||||
const targetId = readTargetId(body.targetId);
|
||||
await runCoreAction(ctx, res, "hover", body, targetId);
|
||||
});
|
||||
|
||||
app.post("/drag", async (req, res) => {
|
||||
const body = readBody(req);
|
||||
const targetId = readTargetId(body.targetId);
|
||||
await runCoreAction(ctx, res, "drag", body, targetId);
|
||||
});
|
||||
|
||||
app.post("/select", async (req, res) => {
|
||||
const body = readBody(req);
|
||||
const targetId = readTargetId(body.targetId);
|
||||
await runCoreAction(ctx, res, "select", body, targetId);
|
||||
});
|
||||
|
||||
app.post("/upload", async (req, res) => {
|
||||
const body = readBody(req);
|
||||
const targetId = readTargetId(body.targetId);
|
||||
await runCoreAction(ctx, res, "upload", body, targetId);
|
||||
});
|
||||
|
||||
app.post("/fill", async (req, res) => {
|
||||
const body = readBody(req);
|
||||
const targetId = readTargetId(body.targetId);
|
||||
await runCoreAction(ctx, res, "fill", body, targetId);
|
||||
});
|
||||
|
||||
app.post("/dialog", async (req, res) => {
|
||||
const body = readBody(req);
|
||||
const targetId = readTargetId(body.targetId);
|
||||
await runCoreAction(ctx, res, "dialog", body, targetId);
|
||||
});
|
||||
|
||||
app.post("/wait", async (req, res) => {
|
||||
const body = readBody(req);
|
||||
const targetId = readTargetId(body.targetId);
|
||||
await runCoreAction(ctx, res, "wait", body, targetId);
|
||||
});
|
||||
|
||||
app.post("/evaluate", async (req, res) => {
|
||||
const body = readBody(req);
|
||||
const targetId = readTargetId(body.targetId);
|
||||
await runCoreAction(ctx, res, "evaluate", body, targetId);
|
||||
});
|
||||
|
||||
app.get("/console", async (req, res) => {
|
||||
const targetId = readTargetId(req.query.targetId);
|
||||
const level = toStringOrEmpty(req.query.level);
|
||||
const args = level ? { level } : {};
|
||||
await runExtraAction(ctx, res, "console", args, targetId);
|
||||
});
|
||||
|
||||
app.post("/pdf", async (req, res) => {
|
||||
const body = readBody(req);
|
||||
const targetId = readTargetId(body.targetId);
|
||||
await runExtraAction(ctx, res, "pdf", body, targetId);
|
||||
});
|
||||
|
||||
app.post("/verify/element", async (req, res) => {
|
||||
const body = readBody(req);
|
||||
const targetId = readTargetId(body.targetId);
|
||||
await runExtraAction(ctx, res, "verifyElement", body, targetId);
|
||||
});
|
||||
|
||||
app.post("/verify/text", async (req, res) => {
|
||||
const body = readBody(req);
|
||||
const targetId = readTargetId(body.targetId);
|
||||
await runExtraAction(ctx, res, "verifyText", body, targetId);
|
||||
});
|
||||
|
||||
app.post("/verify/list", async (req, res) => {
|
||||
const body = readBody(req);
|
||||
const targetId = readTargetId(body.targetId);
|
||||
await runExtraAction(ctx, res, "verifyList", body, targetId);
|
||||
});
|
||||
|
||||
app.post("/verify/value", async (req, res) => {
|
||||
const body = readBody(req);
|
||||
const targetId = readTargetId(body.targetId);
|
||||
await runExtraAction(ctx, res, "verifyValue", body, targetId);
|
||||
});
|
||||
|
||||
// Intentionally no coordinate-based mouse actions (move/click/drag).
|
||||
}
|
||||
456
src/browser/routes/agent.ts
Normal file
456
src/browser/routes/agent.ts
Normal file
@@ -0,0 +1,456 @@
|
||||
import path from "node:path";
|
||||
|
||||
import type express from "express";
|
||||
|
||||
import { ensureMediaDir, saveMediaBuffer } from "../../media/store.js";
|
||||
import { snapshotAria } from "../cdp.js";
|
||||
import {
|
||||
armDialogViaPlaywright,
|
||||
armFileUploadViaPlaywright,
|
||||
clickViaPlaywright,
|
||||
closePageViaPlaywright,
|
||||
dragViaPlaywright,
|
||||
evaluateViaPlaywright,
|
||||
fillFormViaPlaywright,
|
||||
getConsoleMessagesViaPlaywright,
|
||||
hoverViaPlaywright,
|
||||
navigateViaPlaywright,
|
||||
pdfViaPlaywright,
|
||||
pressKeyViaPlaywright,
|
||||
resizeViewportViaPlaywright,
|
||||
selectOptionViaPlaywright,
|
||||
snapshotAiViaPlaywright,
|
||||
takeScreenshotViaPlaywright,
|
||||
typeViaPlaywright,
|
||||
waitForViaPlaywright,
|
||||
} from "../pw-ai.js";
|
||||
import {
|
||||
DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES,
|
||||
DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE,
|
||||
normalizeBrowserScreenshot,
|
||||
} from "../screenshot.js";
|
||||
import type { BrowserRouteContext } from "../server-context.js";
|
||||
import {
|
||||
jsonError,
|
||||
toBoolean,
|
||||
toNumber,
|
||||
toStringArray,
|
||||
toStringOrEmpty,
|
||||
} from "./utils.js";
|
||||
|
||||
type ActKind =
|
||||
| "click"
|
||||
| "close"
|
||||
| "drag"
|
||||
| "evaluate"
|
||||
| "fill"
|
||||
| "hover"
|
||||
| "press"
|
||||
| "resize"
|
||||
| "select"
|
||||
| "type"
|
||||
| "wait";
|
||||
|
||||
type ClickButton = "left" | "right" | "middle";
|
||||
type ClickModifier = "Alt" | "Control" | "ControlOrMeta" | "Meta" | "Shift";
|
||||
|
||||
function readBody(req: express.Request): Record<string, unknown> {
|
||||
const body = req.body as Record<string, unknown> | undefined;
|
||||
if (!body || typeof body !== "object" || Array.isArray(body)) return {};
|
||||
return body;
|
||||
}
|
||||
|
||||
function handleRouteError(
|
||||
ctx: BrowserRouteContext,
|
||||
res: express.Response,
|
||||
err: unknown,
|
||||
) {
|
||||
const mapped = ctx.mapTabError(err);
|
||||
if (mapped) return jsonError(res, mapped.status, mapped.message);
|
||||
jsonError(res, 500, String(err));
|
||||
}
|
||||
|
||||
function parseClickButton(raw: string): ClickButton | undefined {
|
||||
if (raw === "left" || raw === "right" || raw === "middle") return raw;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function registerBrowserAgentRoutes(
|
||||
app: express.Express,
|
||||
ctx: BrowserRouteContext,
|
||||
) {
|
||||
app.post("/navigate", async (req, res) => {
|
||||
const body = readBody(req);
|
||||
const url = toStringOrEmpty(body.url);
|
||||
const targetId = toStringOrEmpty(body.targetId) || undefined;
|
||||
if (!url) return jsonError(res, 400, "url is required");
|
||||
try {
|
||||
const tab = await ctx.ensureTabAvailable(targetId);
|
||||
const result = await navigateViaPlaywright({
|
||||
cdpPort: ctx.state().cdpPort,
|
||||
targetId: tab.targetId,
|
||||
url,
|
||||
});
|
||||
res.json({ ok: true, targetId: tab.targetId, ...result });
|
||||
} catch (err) {
|
||||
handleRouteError(ctx, res, err);
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/act", async (req, res) => {
|
||||
const body = readBody(req);
|
||||
const kind = toStringOrEmpty(body.kind) as ActKind;
|
||||
const targetId = toStringOrEmpty(body.targetId) || undefined;
|
||||
|
||||
if (
|
||||
kind !== "click" &&
|
||||
kind !== "close" &&
|
||||
kind !== "drag" &&
|
||||
kind !== "evaluate" &&
|
||||
kind !== "fill" &&
|
||||
kind !== "hover" &&
|
||||
kind !== "press" &&
|
||||
kind !== "resize" &&
|
||||
kind !== "select" &&
|
||||
kind !== "type" &&
|
||||
kind !== "wait"
|
||||
) {
|
||||
return jsonError(res, 400, "kind is required");
|
||||
}
|
||||
|
||||
try {
|
||||
const tab = await ctx.ensureTabAvailable(targetId);
|
||||
const cdpPort = ctx.state().cdpPort;
|
||||
|
||||
switch (kind) {
|
||||
case "click": {
|
||||
const ref = toStringOrEmpty(body.ref);
|
||||
if (!ref) return jsonError(res, 400, "ref is required");
|
||||
const doubleClick = toBoolean(body.doubleClick) ?? false;
|
||||
const buttonRaw = toStringOrEmpty(body.button) || "";
|
||||
const button = buttonRaw ? parseClickButton(buttonRaw) : undefined;
|
||||
if (buttonRaw && !button)
|
||||
return jsonError(res, 400, "button must be left|right|middle");
|
||||
|
||||
const modifiersRaw = toStringArray(body.modifiers) ?? [];
|
||||
const allowedModifiers = new Set<ClickModifier>([
|
||||
"Alt",
|
||||
"Control",
|
||||
"ControlOrMeta",
|
||||
"Meta",
|
||||
"Shift",
|
||||
]);
|
||||
const invalidModifiers = modifiersRaw.filter(
|
||||
(m) => !allowedModifiers.has(m as ClickModifier),
|
||||
);
|
||||
if (invalidModifiers.length)
|
||||
return jsonError(
|
||||
res,
|
||||
400,
|
||||
"modifiers must be Alt|Control|ControlOrMeta|Meta|Shift",
|
||||
);
|
||||
const modifiers = modifiersRaw.length
|
||||
? (modifiersRaw as ClickModifier[])
|
||||
: undefined;
|
||||
await clickViaPlaywright({
|
||||
cdpPort,
|
||||
targetId: tab.targetId,
|
||||
ref,
|
||||
doubleClick,
|
||||
button,
|
||||
modifiers,
|
||||
});
|
||||
return res.json({ ok: true, targetId: tab.targetId, url: tab.url });
|
||||
}
|
||||
case "type": {
|
||||
const ref = toStringOrEmpty(body.ref);
|
||||
if (!ref) return jsonError(res, 400, "ref is required");
|
||||
if (typeof body.text !== "string")
|
||||
return jsonError(res, 400, "text is required");
|
||||
const text = body.text;
|
||||
const submit = toBoolean(body.submit) ?? false;
|
||||
const slowly = toBoolean(body.slowly) ?? false;
|
||||
await typeViaPlaywright({
|
||||
cdpPort,
|
||||
targetId: tab.targetId,
|
||||
ref,
|
||||
text,
|
||||
submit,
|
||||
slowly,
|
||||
});
|
||||
return res.json({ ok: true, targetId: tab.targetId });
|
||||
}
|
||||
case "press": {
|
||||
const key = toStringOrEmpty(body.key);
|
||||
if (!key) return jsonError(res, 400, "key is required");
|
||||
await pressKeyViaPlaywright({ cdpPort, targetId: tab.targetId, key });
|
||||
return res.json({ ok: true, targetId: tab.targetId });
|
||||
}
|
||||
case "hover": {
|
||||
const ref = toStringOrEmpty(body.ref);
|
||||
if (!ref) return jsonError(res, 400, "ref is required");
|
||||
await hoverViaPlaywright({ cdpPort, targetId: tab.targetId, ref });
|
||||
return res.json({ ok: true, targetId: tab.targetId });
|
||||
}
|
||||
case "drag": {
|
||||
const startRef = toStringOrEmpty(body.startRef);
|
||||
const endRef = toStringOrEmpty(body.endRef);
|
||||
if (!startRef || !endRef)
|
||||
return jsonError(res, 400, "startRef and endRef are required");
|
||||
await dragViaPlaywright({
|
||||
cdpPort,
|
||||
targetId: tab.targetId,
|
||||
startRef,
|
||||
endRef,
|
||||
});
|
||||
return res.json({ ok: true, targetId: tab.targetId });
|
||||
}
|
||||
case "select": {
|
||||
const ref = toStringOrEmpty(body.ref);
|
||||
const values = toStringArray(body.values);
|
||||
if (!ref || !values?.length)
|
||||
return jsonError(res, 400, "ref and values are required");
|
||||
await selectOptionViaPlaywright({
|
||||
cdpPort,
|
||||
targetId: tab.targetId,
|
||||
ref,
|
||||
values,
|
||||
});
|
||||
return res.json({ ok: true, targetId: tab.targetId });
|
||||
}
|
||||
case "fill": {
|
||||
const fields = Array.isArray(body.fields)
|
||||
? (body.fields as Array<Record<string, unknown>>)
|
||||
: null;
|
||||
if (!fields?.length)
|
||||
return jsonError(res, 400, "fields are required");
|
||||
await fillFormViaPlaywright({
|
||||
cdpPort,
|
||||
targetId: tab.targetId,
|
||||
fields,
|
||||
});
|
||||
return res.json({ ok: true, targetId: tab.targetId });
|
||||
}
|
||||
case "resize": {
|
||||
const width = toNumber(body.width);
|
||||
const height = toNumber(body.height);
|
||||
if (!width || !height)
|
||||
return jsonError(res, 400, "width and height are required");
|
||||
await resizeViewportViaPlaywright({
|
||||
cdpPort,
|
||||
targetId: tab.targetId,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
return res.json({ ok: true, targetId: tab.targetId, url: tab.url });
|
||||
}
|
||||
case "wait": {
|
||||
const timeMs = toNumber(body.timeMs);
|
||||
const text = toStringOrEmpty(body.text) || undefined;
|
||||
const textGone = toStringOrEmpty(body.textGone) || undefined;
|
||||
await waitForViaPlaywright({
|
||||
cdpPort,
|
||||
targetId: tab.targetId,
|
||||
timeMs,
|
||||
text,
|
||||
textGone,
|
||||
});
|
||||
return res.json({ ok: true, targetId: tab.targetId });
|
||||
}
|
||||
case "evaluate": {
|
||||
const fn = toStringOrEmpty(body.fn);
|
||||
if (!fn) return jsonError(res, 400, "fn is required");
|
||||
const ref = toStringOrEmpty(body.ref) || undefined;
|
||||
const result = await evaluateViaPlaywright({
|
||||
cdpPort,
|
||||
targetId: tab.targetId,
|
||||
fn,
|
||||
ref,
|
||||
});
|
||||
return res.json({
|
||||
ok: true,
|
||||
targetId: tab.targetId,
|
||||
url: tab.url,
|
||||
result,
|
||||
});
|
||||
}
|
||||
case "close": {
|
||||
await closePageViaPlaywright({ cdpPort, targetId: tab.targetId });
|
||||
return res.json({ ok: true, targetId: tab.targetId });
|
||||
}
|
||||
default: {
|
||||
return jsonError(res, 400, "unsupported kind");
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
handleRouteError(ctx, res, err);
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/hooks/file-chooser", async (req, res) => {
|
||||
const body = readBody(req);
|
||||
const targetId = toStringOrEmpty(body.targetId) || undefined;
|
||||
const paths = toStringArray(body.paths) ?? [];
|
||||
const timeoutMs = toNumber(body.timeoutMs);
|
||||
if (!paths.length) return jsonError(res, 400, "paths are required");
|
||||
try {
|
||||
const tab = await ctx.ensureTabAvailable(targetId);
|
||||
await armFileUploadViaPlaywright({
|
||||
cdpPort: ctx.state().cdpPort,
|
||||
targetId: tab.targetId,
|
||||
paths,
|
||||
timeoutMs: timeoutMs ?? undefined,
|
||||
});
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
handleRouteError(ctx, res, err);
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/hooks/dialog", async (req, res) => {
|
||||
const body = readBody(req);
|
||||
const targetId = toStringOrEmpty(body.targetId) || undefined;
|
||||
const accept = toBoolean(body.accept);
|
||||
const promptText = toStringOrEmpty(body.promptText) || undefined;
|
||||
const timeoutMs = toNumber(body.timeoutMs);
|
||||
if (accept === undefined) return jsonError(res, 400, "accept is required");
|
||||
try {
|
||||
const tab = await ctx.ensureTabAvailable(targetId);
|
||||
await armDialogViaPlaywright({
|
||||
cdpPort: ctx.state().cdpPort,
|
||||
targetId: tab.targetId,
|
||||
accept,
|
||||
promptText,
|
||||
timeoutMs: timeoutMs ?? undefined,
|
||||
});
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
handleRouteError(ctx, res, err);
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/console", async (req, res) => {
|
||||
const targetId =
|
||||
typeof req.query.targetId === "string" ? req.query.targetId.trim() : "";
|
||||
const level = typeof req.query.level === "string" ? req.query.level : "";
|
||||
|
||||
try {
|
||||
const tab = await ctx.ensureTabAvailable(targetId || undefined);
|
||||
const messages = await getConsoleMessagesViaPlaywright({
|
||||
cdpPort: ctx.state().cdpPort,
|
||||
targetId: tab.targetId,
|
||||
level: level.trim() || undefined,
|
||||
});
|
||||
res.json({ ok: true, messages, targetId: tab.targetId });
|
||||
} catch (err) {
|
||||
handleRouteError(ctx, res, err);
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/pdf", async (req, res) => {
|
||||
const body = readBody(req);
|
||||
const targetId = toStringOrEmpty(body.targetId) || undefined;
|
||||
try {
|
||||
const tab = await ctx.ensureTabAvailable(targetId);
|
||||
const pdf = await pdfViaPlaywright({
|
||||
cdpPort: ctx.state().cdpPort,
|
||||
targetId: tab.targetId,
|
||||
});
|
||||
await ensureMediaDir();
|
||||
const saved = await saveMediaBuffer(
|
||||
pdf.buffer,
|
||||
"application/pdf",
|
||||
"browser",
|
||||
pdf.buffer.byteLength,
|
||||
);
|
||||
res.json({
|
||||
ok: true,
|
||||
path: path.resolve(saved.path),
|
||||
targetId: tab.targetId,
|
||||
url: tab.url,
|
||||
});
|
||||
} catch (err) {
|
||||
handleRouteError(ctx, res, err);
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/screenshot", async (req, res) => {
|
||||
const body = readBody(req);
|
||||
const targetId = toStringOrEmpty(body.targetId) || undefined;
|
||||
const fullPage = toBoolean(body.fullPage) ?? false;
|
||||
const ref = toStringOrEmpty(body.ref) || undefined;
|
||||
const element = toStringOrEmpty(body.element) || undefined;
|
||||
const type = body.type === "jpeg" ? "jpeg" : "png";
|
||||
|
||||
try {
|
||||
const tab = await ctx.ensureTabAvailable(targetId);
|
||||
const snap = await takeScreenshotViaPlaywright({
|
||||
cdpPort: ctx.state().cdpPort,
|
||||
targetId: tab.targetId,
|
||||
ref,
|
||||
element,
|
||||
fullPage,
|
||||
type,
|
||||
});
|
||||
|
||||
const normalized = await normalizeBrowserScreenshot(snap.buffer, {
|
||||
maxSide: DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE,
|
||||
maxBytes: DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES,
|
||||
});
|
||||
await ensureMediaDir();
|
||||
const saved = await saveMediaBuffer(
|
||||
normalized.buffer,
|
||||
normalized.contentType ?? `image/${type}`,
|
||||
"browser",
|
||||
DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES,
|
||||
);
|
||||
res.json({
|
||||
ok: true,
|
||||
path: path.resolve(saved.path),
|
||||
targetId: tab.targetId,
|
||||
url: tab.url,
|
||||
});
|
||||
} catch (err) {
|
||||
handleRouteError(ctx, res, err);
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/snapshot", async (req, res) => {
|
||||
const targetId =
|
||||
typeof req.query.targetId === "string" ? req.query.targetId.trim() : "";
|
||||
const format = req.query.format === "aria" ? "aria" : "ai";
|
||||
const limit =
|
||||
typeof req.query.limit === "string" ? Number(req.query.limit) : undefined;
|
||||
|
||||
try {
|
||||
const tab = await ctx.ensureTabAvailable(targetId || undefined);
|
||||
if (format === "ai") {
|
||||
const snap = await snapshotAiViaPlaywright({
|
||||
cdpPort: ctx.state().cdpPort,
|
||||
targetId: tab.targetId,
|
||||
});
|
||||
return res.json({
|
||||
ok: true,
|
||||
format,
|
||||
targetId: tab.targetId,
|
||||
url: tab.url,
|
||||
...snap,
|
||||
});
|
||||
}
|
||||
|
||||
const snap = await snapshotAria({
|
||||
wsUrl: tab.wsUrl ?? "",
|
||||
limit,
|
||||
});
|
||||
return res.json({
|
||||
ok: true,
|
||||
format,
|
||||
targetId: tab.targetId,
|
||||
url: tab.url,
|
||||
...snap,
|
||||
});
|
||||
} catch (err) {
|
||||
handleRouteError(ctx, res, err);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
import type express from "express";
|
||||
|
||||
import type { BrowserRouteContext } from "../server-context.js";
|
||||
import { registerBrowserActionRoutes } from "./actions.js";
|
||||
import { registerBrowserAgentRoutes } from "./agent.js";
|
||||
import { registerBrowserBasicRoutes } from "./basic.js";
|
||||
import { registerBrowserInspectRoutes } from "./inspect.js";
|
||||
import { registerBrowserTabRoutes } from "./tabs.js";
|
||||
|
||||
export function registerBrowserRoutes(
|
||||
@@ -12,6 +11,5 @@ export function registerBrowserRoutes(
|
||||
) {
|
||||
registerBrowserBasicRoutes(app, ctx);
|
||||
registerBrowserTabRoutes(app, ctx);
|
||||
registerBrowserInspectRoutes(app, ctx);
|
||||
registerBrowserActionRoutes(app, ctx);
|
||||
registerBrowserAgentRoutes(app, ctx);
|
||||
}
|
||||
|
||||
@@ -1,244 +0,0 @@
|
||||
import path from "node:path";
|
||||
|
||||
import type express from "express";
|
||||
import { ensureMediaDir, saveMediaBuffer } from "../../media/store.js";
|
||||
import {
|
||||
captureScreenshot,
|
||||
captureScreenshotPng,
|
||||
getDomText,
|
||||
querySelector,
|
||||
snapshotAria,
|
||||
snapshotDom,
|
||||
} from "../cdp.js";
|
||||
import {
|
||||
snapshotAiViaPlaywright,
|
||||
takeScreenshotViaPlaywright,
|
||||
} from "../pw-ai.js";
|
||||
import {
|
||||
DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES,
|
||||
DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE,
|
||||
normalizeBrowserScreenshot,
|
||||
} from "../screenshot.js";
|
||||
import type { BrowserRouteContext } from "../server-context.js";
|
||||
import { jsonError, toBoolean, toStringOrEmpty } from "./utils.js";
|
||||
|
||||
export function registerBrowserInspectRoutes(
|
||||
app: express.Express,
|
||||
ctx: BrowserRouteContext,
|
||||
) {
|
||||
app.get("/screenshot", async (req, res) => {
|
||||
const targetId =
|
||||
typeof req.query.targetId === "string" ? req.query.targetId.trim() : "";
|
||||
const fullPage =
|
||||
req.query.fullPage === "true" || req.query.fullPage === "1";
|
||||
|
||||
try {
|
||||
const tab = await ctx.ensureTabAvailable(targetId || undefined);
|
||||
|
||||
let shot: Buffer<ArrayBufferLike> = Buffer.alloc(0);
|
||||
let contentTypeHint: "image/jpeg" | "image/png" = "image/jpeg";
|
||||
try {
|
||||
shot = await captureScreenshot({
|
||||
wsUrl: tab.wsUrl ?? "",
|
||||
fullPage,
|
||||
format: "jpeg",
|
||||
quality: 85,
|
||||
});
|
||||
} catch {
|
||||
contentTypeHint = "image/png";
|
||||
shot = await captureScreenshotPng({
|
||||
wsUrl: tab.wsUrl ?? "",
|
||||
fullPage,
|
||||
});
|
||||
}
|
||||
|
||||
const normalized = await normalizeBrowserScreenshot(shot, {
|
||||
maxSide: DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE,
|
||||
maxBytes: DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES,
|
||||
});
|
||||
await ensureMediaDir();
|
||||
const saved = await saveMediaBuffer(
|
||||
normalized.buffer,
|
||||
normalized.contentType ?? contentTypeHint,
|
||||
"browser",
|
||||
DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES,
|
||||
);
|
||||
const filePath = path.resolve(saved.path);
|
||||
res.json({
|
||||
ok: true,
|
||||
path: filePath,
|
||||
targetId: tab.targetId,
|
||||
url: tab.url,
|
||||
});
|
||||
} catch (err) {
|
||||
const mapped = ctx.mapTabError(err);
|
||||
if (mapped) return jsonError(res, mapped.status, mapped.message);
|
||||
jsonError(res, 500, String(err));
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/screenshot", async (req, res) => {
|
||||
const body = req.body as Record<string, unknown>;
|
||||
const targetId = toStringOrEmpty(body?.targetId);
|
||||
const fullPage = toBoolean(body?.fullPage) ?? false;
|
||||
const ref = toStringOrEmpty(body?.ref);
|
||||
const element = toStringOrEmpty(body?.element);
|
||||
const type = body?.type === "jpeg" ? "jpeg" : "png";
|
||||
const filename = toStringOrEmpty(body?.filename);
|
||||
|
||||
try {
|
||||
const tab = await ctx.ensureTabAvailable(targetId || undefined);
|
||||
const snap = await takeScreenshotViaPlaywright({
|
||||
cdpPort: ctx.state().cdpPort,
|
||||
targetId: tab.targetId,
|
||||
ref,
|
||||
element,
|
||||
fullPage,
|
||||
type,
|
||||
});
|
||||
const buffer = snap.buffer;
|
||||
const normalized = await normalizeBrowserScreenshot(buffer, {
|
||||
maxSide: DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE,
|
||||
maxBytes: DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES,
|
||||
});
|
||||
await ensureMediaDir();
|
||||
const saved = await saveMediaBuffer(
|
||||
normalized.buffer,
|
||||
normalized.contentType ?? `image/${type}`,
|
||||
"browser",
|
||||
DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES,
|
||||
);
|
||||
const filePath = path.resolve(saved.path);
|
||||
res.json({
|
||||
ok: true,
|
||||
path: filePath,
|
||||
targetId: tab.targetId,
|
||||
url: tab.url,
|
||||
filename: filename || undefined,
|
||||
});
|
||||
} catch (err) {
|
||||
const mapped = ctx.mapTabError(err);
|
||||
if (mapped) return jsonError(res, mapped.status, mapped.message);
|
||||
jsonError(res, 500, String(err));
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/query", async (req, res) => {
|
||||
const selector =
|
||||
typeof req.query.selector === "string" ? req.query.selector.trim() : "";
|
||||
const targetId =
|
||||
typeof req.query.targetId === "string" ? req.query.targetId.trim() : "";
|
||||
const limit =
|
||||
typeof req.query.limit === "string" ? Number(req.query.limit) : undefined;
|
||||
|
||||
if (!selector) return jsonError(res, 400, "selector is required");
|
||||
|
||||
try {
|
||||
const tab = await ctx.ensureTabAvailable(targetId || undefined);
|
||||
const result = await querySelector({
|
||||
wsUrl: tab.wsUrl ?? "",
|
||||
selector,
|
||||
limit,
|
||||
});
|
||||
res.json({ ok: true, targetId: tab.targetId, url: tab.url, ...result });
|
||||
} catch (err) {
|
||||
const mapped = ctx.mapTabError(err);
|
||||
if (mapped) return jsonError(res, mapped.status, mapped.message);
|
||||
jsonError(res, 500, String(err));
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/dom", async (req, res) => {
|
||||
const targetId =
|
||||
typeof req.query.targetId === "string" ? req.query.targetId.trim() : "";
|
||||
const format = req.query.format === "text" ? "text" : "html";
|
||||
const selector =
|
||||
typeof req.query.selector === "string" ? req.query.selector.trim() : "";
|
||||
const maxChars =
|
||||
typeof req.query.maxChars === "string"
|
||||
? Number(req.query.maxChars)
|
||||
: undefined;
|
||||
|
||||
try {
|
||||
const tab = await ctx.ensureTabAvailable(targetId || undefined);
|
||||
const result = await getDomText({
|
||||
wsUrl: tab.wsUrl ?? "",
|
||||
format,
|
||||
maxChars,
|
||||
selector: selector || undefined,
|
||||
});
|
||||
res.json({
|
||||
ok: true,
|
||||
targetId: tab.targetId,
|
||||
url: tab.url,
|
||||
format,
|
||||
...result,
|
||||
});
|
||||
} catch (err) {
|
||||
const mapped = ctx.mapTabError(err);
|
||||
if (mapped) return jsonError(res, mapped.status, mapped.message);
|
||||
jsonError(res, 500, String(err));
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/snapshot", async (req, res) => {
|
||||
const targetId =
|
||||
typeof req.query.targetId === "string" ? req.query.targetId.trim() : "";
|
||||
const format =
|
||||
req.query.format === "domSnapshot"
|
||||
? "domSnapshot"
|
||||
: req.query.format === "ai"
|
||||
? "ai"
|
||||
: "aria";
|
||||
const limit =
|
||||
typeof req.query.limit === "string" ? Number(req.query.limit) : undefined;
|
||||
|
||||
try {
|
||||
const tab = await ctx.ensureTabAvailable(targetId || undefined);
|
||||
|
||||
if (format === "ai") {
|
||||
const snap = await snapshotAiViaPlaywright({
|
||||
cdpPort: ctx.state().cdpPort,
|
||||
targetId: tab.targetId,
|
||||
});
|
||||
return res.json({
|
||||
ok: true,
|
||||
format,
|
||||
targetId: tab.targetId,
|
||||
url: tab.url,
|
||||
...snap,
|
||||
});
|
||||
}
|
||||
|
||||
if (format === "aria") {
|
||||
const snap = await snapshotAria({
|
||||
wsUrl: tab.wsUrl ?? "",
|
||||
limit,
|
||||
});
|
||||
return res.json({
|
||||
ok: true,
|
||||
format,
|
||||
targetId: tab.targetId,
|
||||
url: tab.url,
|
||||
...snap,
|
||||
});
|
||||
}
|
||||
|
||||
const snap = await snapshotDom({
|
||||
wsUrl: tab.wsUrl ?? "",
|
||||
limit,
|
||||
});
|
||||
return res.json({
|
||||
ok: true,
|
||||
format,
|
||||
targetId: tab.targetId,
|
||||
url: tab.url,
|
||||
...snap,
|
||||
});
|
||||
} catch (err) {
|
||||
const mapped = ctx.mapTabError(err);
|
||||
if (mapped) return jsonError(res, mapped.status, mapped.message);
|
||||
jsonError(res, 500, String(err));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -7,7 +7,6 @@ let testPort = 0;
|
||||
let reachable = false;
|
||||
let cfgAttachOnly = false;
|
||||
let createTargetId: string | null = null;
|
||||
let screenshotThrowsOnce = false;
|
||||
|
||||
function makeProc(pid = 123) {
|
||||
const handlers = new Map<string, Array<(...args: unknown[]) => void>>();
|
||||
@@ -67,28 +66,14 @@ vi.mock("./cdp.js", () => ({
|
||||
if (createTargetId) return { targetId: createTargetId };
|
||||
throw new Error("cdp disabled");
|
||||
}),
|
||||
getDomText: vi.fn(async () => ({ text: "<html/>" })),
|
||||
querySelector: vi.fn(async () => ({ matches: [{ index: 0, tag: "a" }] })),
|
||||
snapshotAria: vi.fn(async () => ({
|
||||
nodes: [{ ref: "1", role: "link", name: "x", depth: 0 }],
|
||||
})),
|
||||
snapshotDom: vi.fn(async () => ({
|
||||
nodes: [{ ref: "1", parentRef: null, depth: 0, tag: "html" }],
|
||||
})),
|
||||
captureScreenshot: vi.fn(async () => {
|
||||
if (screenshotThrowsOnce) {
|
||||
screenshotThrowsOnce = false;
|
||||
throw new Error("jpeg failed");
|
||||
}
|
||||
return Buffer.from("jpg");
|
||||
}),
|
||||
captureScreenshotPng: vi.fn(async () => Buffer.from("png")),
|
||||
}));
|
||||
|
||||
vi.mock("./pw-ai.js", () => ({
|
||||
armDialogViaPlaywright: vi.fn(async () => {}),
|
||||
armFileUploadViaPlaywright: vi.fn(async () => {}),
|
||||
clickRefViaPlaywright: vi.fn(async () => {}),
|
||||
clickViaPlaywright: vi.fn(async () => {}),
|
||||
closePageViaPlaywright: vi.fn(async () => {}),
|
||||
closePlaywrightBrowserConnection: vi.fn(async () => {}),
|
||||
@@ -106,10 +91,6 @@ vi.mock("./pw-ai.js", () => ({
|
||||
buffer: Buffer.from("png"),
|
||||
})),
|
||||
typeViaPlaywright: vi.fn(async () => {}),
|
||||
verifyElementVisibleViaPlaywright: vi.fn(async () => {}),
|
||||
verifyListVisibleViaPlaywright: vi.fn(async () => {}),
|
||||
verifyTextVisibleViaPlaywright: vi.fn(async () => {}),
|
||||
verifyValueViaPlaywright: vi.fn(async () => {}),
|
||||
waitForViaPlaywright: vi.fn(async () => {}),
|
||||
dragViaPlaywright: vi.fn(async () => {}),
|
||||
}));
|
||||
@@ -159,7 +140,6 @@ describe("browser control server", () => {
|
||||
reachable = false;
|
||||
cfgAttachOnly = false;
|
||||
createTargetId = null;
|
||||
screenshotThrowsOnce = false;
|
||||
|
||||
testPort = await getFreePort();
|
||||
|
||||
@@ -270,24 +250,12 @@ describe("browser control server", () => {
|
||||
expect(focus.status).toBe(409);
|
||||
});
|
||||
|
||||
it("supports query/dom/snapshot/click/screenshot and stop", async () => {
|
||||
it("supports the agent contract and stop", async () => {
|
||||
const { startBrowserControlServerFromConfig } = await import("./server.js");
|
||||
await startBrowserControlServerFromConfig();
|
||||
const base = `http://127.0.0.1:${testPort}`;
|
||||
await realFetch(`${base}/start`, { method: "POST" }).then((r) => r.json());
|
||||
|
||||
const query = (await realFetch(`${base}/query?selector=a&limit=1`).then(
|
||||
(r) => r.json(),
|
||||
)) as { ok: boolean; matches?: unknown[] };
|
||||
expect(query.ok).toBe(true);
|
||||
expect(Array.isArray(query.matches)).toBe(true);
|
||||
|
||||
const dom = (await realFetch(`${base}/dom?format=text&maxChars=10`).then(
|
||||
(r) => r.json(),
|
||||
)) as { ok: boolean; text?: string };
|
||||
expect(dom.ok).toBe(true);
|
||||
expect(typeof dom.text).toBe("string");
|
||||
|
||||
const snapAria = (await realFetch(
|
||||
`${base}/snapshot?format=aria&limit=1`,
|
||||
).then((r) => r.json())) as {
|
||||
@@ -304,16 +272,61 @@ describe("browser control server", () => {
|
||||
expect(snapAi.ok).toBe(true);
|
||||
expect(snapAi.format).toBe("ai");
|
||||
|
||||
const click = (await realFetch(`${base}/click`, {
|
||||
const nav = (await realFetch(`${base}/navigate`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ref: "1" }),
|
||||
body: JSON.stringify({ url: "https://example.com" }),
|
||||
}).then((r) => r.json())) as { ok: boolean; targetId?: string };
|
||||
expect(nav.ok).toBe(true);
|
||||
expect(typeof nav.targetId).toBe("string");
|
||||
|
||||
const click = (await realFetch(`${base}/act`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ kind: "click", ref: "1" }),
|
||||
}).then((r) => r.json())) as { ok: boolean };
|
||||
expect(click.ok).toBe(true);
|
||||
|
||||
const shot = (await realFetch(`${base}/screenshot?fullPage=true`).then(
|
||||
(r) => r.json(),
|
||||
)) as { ok: boolean; path?: string };
|
||||
const evalRes = (await realFetch(`${base}/act`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ kind: "evaluate", fn: "() => 1" }),
|
||||
}).then((r) => r.json())) as { ok: boolean; result?: unknown };
|
||||
expect(evalRes.ok).toBe(true);
|
||||
|
||||
const upload = await realFetch(`${base}/hooks/file-chooser`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ paths: ["/tmp/a.txt"] }),
|
||||
}).then((r) => r.json());
|
||||
expect(upload).toMatchObject({ ok: true });
|
||||
|
||||
const dialog = await realFetch(`${base}/hooks/dialog`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ accept: true }),
|
||||
}).then((r) => r.json());
|
||||
expect(dialog).toMatchObject({ ok: true });
|
||||
|
||||
const consoleRes = (await realFetch(`${base}/console`).then((r) =>
|
||||
r.json(),
|
||||
)) as { ok: boolean; messages?: unknown[] };
|
||||
expect(consoleRes.ok).toBe(true);
|
||||
expect(Array.isArray(consoleRes.messages)).toBe(true);
|
||||
|
||||
const pdf = (await realFetch(`${base}/pdf`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
}).then((r) => r.json())) as { ok: boolean; path?: string };
|
||||
expect(pdf.ok).toBe(true);
|
||||
expect(typeof pdf.path).toBe("string");
|
||||
|
||||
const shot = (await realFetch(`${base}/screenshot`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ fullPage: true }),
|
||||
}).then((r) => r.json())) as { ok: boolean; path?: string };
|
||||
expect(shot.ok).toBe(true);
|
||||
expect(typeof shot.path).toBe("string");
|
||||
|
||||
@@ -344,7 +357,7 @@ describe("browser control server", () => {
|
||||
expect(started.error ?? "").toMatch(/attachOnly/i);
|
||||
});
|
||||
|
||||
it("opens tabs via CDP createTarget path and falls back to PNG screenshots", async () => {
|
||||
it("opens tabs via CDP createTarget path", async () => {
|
||||
const { startBrowserControlServerFromConfig } = await import("./server.js");
|
||||
await startBrowserControlServerFromConfig();
|
||||
const base = `http://127.0.0.1:${testPort}`;
|
||||
@@ -357,13 +370,6 @@ describe("browser control server", () => {
|
||||
body: JSON.stringify({ url: "https://example.com" }),
|
||||
}).then((r) => r.json())) as { targetId?: string };
|
||||
expect(opened.targetId).toBe("abcd1234");
|
||||
|
||||
screenshotThrowsOnce = true;
|
||||
const shot = (await realFetch(`${base}/screenshot`).then((r) =>
|
||||
r.json(),
|
||||
)) as { ok: boolean; path?: string };
|
||||
expect(shot.ok).toBe(true);
|
||||
expect(typeof shot.path).toBe("string");
|
||||
});
|
||||
|
||||
it("covers additional endpoint branches", async () => {
|
||||
@@ -398,16 +404,9 @@ describe("browser control server", () => {
|
||||
});
|
||||
expect(delAmbiguous.status).toBe(409);
|
||||
|
||||
const shotAmbiguous = await realFetch(`${base}/screenshot?targetId=abc`);
|
||||
expect(shotAmbiguous.status).toBe(409);
|
||||
|
||||
const queryMissing = await realFetch(`${base}/query`);
|
||||
expect(queryMissing.status).toBe(400);
|
||||
|
||||
const snapDom = (await realFetch(
|
||||
`${base}/snapshot?format=domSnapshot&limit=1`,
|
||||
).then((r) => r.json())) as { ok: boolean; format?: string };
|
||||
expect(snapDom.ok).toBe(true);
|
||||
expect(snapDom.format).toBe("domSnapshot");
|
||||
const snapAmbiguous = await realFetch(
|
||||
`${base}/snapshot?format=aria&targetId=abc`,
|
||||
);
|
||||
expect(snapAmbiguous.status).toBe(409);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,19 +1,10 @@
|
||||
import type { Command } from "commander";
|
||||
import { resolveBrowserControlUrl } from "../browser/client.js";
|
||||
import {
|
||||
browserClick,
|
||||
browserDrag,
|
||||
browserEvaluate,
|
||||
browserFillForm,
|
||||
browserHandleDialog,
|
||||
browserHover,
|
||||
browserAct,
|
||||
browserArmDialog,
|
||||
browserArmFileChooser,
|
||||
browserNavigate,
|
||||
browserPressKey,
|
||||
browserResize,
|
||||
browserSelectOption,
|
||||
browserType,
|
||||
browserUpload,
|
||||
browserWaitFor,
|
||||
} from "../browser/client-actions.js";
|
||||
import { danger } from "../globals.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
@@ -80,7 +71,8 @@ export function registerBrowserActionInputCommands(
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await browserResize(baseUrl, {
|
||||
const result = await browserAct(baseUrl, {
|
||||
kind: "resize",
|
||||
width,
|
||||
height,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
@@ -114,7 +106,8 @@ export function registerBrowserActionInputCommands(
|
||||
.filter(Boolean)
|
||||
: undefined;
|
||||
try {
|
||||
const result = await browserClick(baseUrl, {
|
||||
const result = await browserAct(baseUrl, {
|
||||
kind: "click",
|
||||
ref,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
doubleClick: Boolean(opts.double),
|
||||
@@ -145,7 +138,8 @@ export function registerBrowserActionInputCommands(
|
||||
const parent = parentOpts(cmd);
|
||||
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||
try {
|
||||
const result = await browserType(baseUrl, {
|
||||
const result = await browserAct(baseUrl, {
|
||||
kind: "type",
|
||||
ref,
|
||||
text,
|
||||
submit: Boolean(opts.submit),
|
||||
@@ -172,7 +166,8 @@ export function registerBrowserActionInputCommands(
|
||||
const parent = parentOpts(cmd);
|
||||
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||
try {
|
||||
const result = await browserPressKey(baseUrl, {
|
||||
const result = await browserAct(baseUrl, {
|
||||
kind: "press",
|
||||
key,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
});
|
||||
@@ -196,7 +191,8 @@ export function registerBrowserActionInputCommands(
|
||||
const parent = parentOpts(cmd);
|
||||
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||
try {
|
||||
const result = await browserHover(baseUrl, {
|
||||
const result = await browserAct(baseUrl, {
|
||||
kind: "hover",
|
||||
ref,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
});
|
||||
@@ -221,7 +217,8 @@ export function registerBrowserActionInputCommands(
|
||||
const parent = parentOpts(cmd);
|
||||
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||
try {
|
||||
const result = await browserDrag(baseUrl, {
|
||||
const result = await browserAct(baseUrl, {
|
||||
kind: "drag",
|
||||
startRef,
|
||||
endRef,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
@@ -247,7 +244,8 @@ export function registerBrowserActionInputCommands(
|
||||
const parent = parentOpts(cmd);
|
||||
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||
try {
|
||||
const result = await browserSelectOption(baseUrl, {
|
||||
const result = await browserAct(baseUrl, {
|
||||
kind: "select",
|
||||
ref,
|
||||
values,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
@@ -268,13 +266,21 @@ export function registerBrowserActionInputCommands(
|
||||
.description("Arm file upload for the next file chooser")
|
||||
.argument("<paths...>", "File paths to upload")
|
||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||
.option(
|
||||
"--timeout-ms <ms>",
|
||||
"How long to wait for the next file chooser (default: 10000)",
|
||||
(v: string) => Number(v),
|
||||
)
|
||||
.action(async (paths: string[], opts, cmd) => {
|
||||
const parent = parentOpts(cmd);
|
||||
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||
try {
|
||||
const result = await browserUpload(baseUrl, {
|
||||
const result = await browserArmFileChooser(baseUrl, {
|
||||
paths,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
timeoutMs: Number.isFinite(opts.timeoutMs)
|
||||
? opts.timeoutMs
|
||||
: undefined,
|
||||
});
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
@@ -301,7 +307,8 @@ export function registerBrowserActionInputCommands(
|
||||
fields: opts.fields,
|
||||
fieldsFile: opts.fieldsFile,
|
||||
});
|
||||
const result = await browserFillForm(baseUrl, {
|
||||
const result = await browserAct(baseUrl, {
|
||||
kind: "fill",
|
||||
fields,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
});
|
||||
@@ -323,6 +330,11 @@ export function registerBrowserActionInputCommands(
|
||||
.option("--dismiss", "Dismiss the dialog", false)
|
||||
.option("--prompt <text>", "Prompt response text")
|
||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||
.option(
|
||||
"--timeout-ms <ms>",
|
||||
"How long to wait for the next dialog (default: 10000)",
|
||||
(v: string) => Number(v),
|
||||
)
|
||||
.action(async (opts, cmd) => {
|
||||
const parent = parentOpts(cmd);
|
||||
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||
@@ -333,10 +345,13 @@ export function registerBrowserActionInputCommands(
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await browserHandleDialog(baseUrl, {
|
||||
const result = await browserArmDialog(baseUrl, {
|
||||
accept,
|
||||
promptText: opts.prompt?.trim() || undefined,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
timeoutMs: Number.isFinite(opts.timeoutMs)
|
||||
? opts.timeoutMs
|
||||
: undefined,
|
||||
});
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
@@ -360,8 +375,9 @@ export function registerBrowserActionInputCommands(
|
||||
const parent = parentOpts(cmd);
|
||||
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||
try {
|
||||
const result = await browserWaitFor(baseUrl, {
|
||||
time: Number.isFinite(opts.time) ? opts.time : undefined,
|
||||
const result = await browserAct(baseUrl, {
|
||||
kind: "wait",
|
||||
timeMs: Number.isFinite(opts.time) ? opts.time : undefined,
|
||||
text: opts.text?.trim() || undefined,
|
||||
textGone: opts.textGone?.trim() || undefined,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
@@ -392,7 +408,8 @@ export function registerBrowserActionInputCommands(
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await browserEvaluate(baseUrl, {
|
||||
const result = await browserAct(baseUrl, {
|
||||
kind: "evaluate",
|
||||
fn: opts.fn,
|
||||
ref: opts.ref?.trim() || undefined,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
@@ -401,7 +418,7 @@ export function registerBrowserActionInputCommands(
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
defaultRuntime.log(JSON.stringify(result.result, null, 2));
|
||||
defaultRuntime.log(JSON.stringify(result.result ?? null, null, 2));
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
|
||||
@@ -3,10 +3,6 @@ import { resolveBrowserControlUrl } from "../browser/client.js";
|
||||
import {
|
||||
browserConsoleMessages,
|
||||
browserPdfSave,
|
||||
browserVerifyElementVisible,
|
||||
browserVerifyListVisible,
|
||||
browserVerifyTextVisible,
|
||||
browserVerifyValue,
|
||||
} from "../browser/client-actions.js";
|
||||
import { danger } from "../globals.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
@@ -61,118 +57,4 @@ export function registerBrowserActionObserveCommands(
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
browser
|
||||
.command("verify-element")
|
||||
.description("Verify element visible by role + name")
|
||||
.option("--role <role>", "ARIA role")
|
||||
.option("--name <text>", "Accessible name")
|
||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||
.action(async (opts, cmd) => {
|
||||
const parent = parentOpts(cmd);
|
||||
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||
if (!opts.role || !opts.name) {
|
||||
defaultRuntime.error(danger("--role and --name are required"));
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await browserVerifyElementVisible(baseUrl, {
|
||||
role: opts.role,
|
||||
accessibleName: opts.name,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
});
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
defaultRuntime.log("element visible");
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
browser
|
||||
.command("verify-text")
|
||||
.description("Verify text is visible")
|
||||
.argument("<text>", "Text to find")
|
||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||
.action(async (text: string, opts, cmd) => {
|
||||
const parent = parentOpts(cmd);
|
||||
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||
try {
|
||||
const result = await browserVerifyTextVisible(baseUrl, {
|
||||
text,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
});
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
defaultRuntime.log("text visible");
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
browser
|
||||
.command("verify-list")
|
||||
.description("Verify list items under a ref")
|
||||
.argument("<ref>", "Ref id from ai snapshot")
|
||||
.argument("<items...>", "List items to verify")
|
||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||
.action(async (ref: string, items: string[], opts, cmd) => {
|
||||
const parent = parentOpts(cmd);
|
||||
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||
try {
|
||||
const result = await browserVerifyListVisible(baseUrl, {
|
||||
ref,
|
||||
items,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
});
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
defaultRuntime.log("list visible");
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
browser
|
||||
.command("verify-value")
|
||||
.description("Verify a form control value")
|
||||
.option("--ref <ref>", "Ref id from ai snapshot")
|
||||
.option("--type <type>", "Input type (textbox, checkbox, slider, etc)")
|
||||
.option("--value <value>", "Expected value")
|
||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||
.action(async (opts, cmd) => {
|
||||
const parent = parentOpts(cmd);
|
||||
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||
if (!opts.ref || !opts.type) {
|
||||
defaultRuntime.error(danger("--ref and --type are required"));
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await browserVerifyValue(baseUrl, {
|
||||
ref: opts.ref,
|
||||
type: opts.type,
|
||||
value: opts.value,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
});
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
defaultRuntime.log("value verified");
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -9,8 +9,6 @@ export const browserCoreExamples = [
|
||||
"clawdis browser screenshot",
|
||||
"clawdis browser screenshot --full-page",
|
||||
"clawdis browser screenshot --ref 12",
|
||||
'clawdis browser query "a" --limit 5',
|
||||
"clawdis browser dom --format text --max-chars 5000",
|
||||
"clawdis browser snapshot --format aria --limit 200",
|
||||
"clawdis browser snapshot --format ai",
|
||||
];
|
||||
@@ -31,8 +29,4 @@ export const browserActionExamples = [
|
||||
"clawdis browser evaluate --fn '(el) => el.textContent' --ref 7",
|
||||
"clawdis browser console --level error",
|
||||
"clawdis browser pdf",
|
||||
'clawdis browser verify-element --role button --name "Submit"',
|
||||
'clawdis browser verify-text "Welcome"',
|
||||
"clawdis browser verify-list 3 ItemA ItemB",
|
||||
"clawdis browser verify-value --ref 4 --type textbox --value hello",
|
||||
];
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import type { Command } from "commander";
|
||||
|
||||
import {
|
||||
browserDom,
|
||||
browserQuery,
|
||||
browserScreenshot,
|
||||
browserSnapshot,
|
||||
resolveBrowserControlUrl,
|
||||
} from "../browser/client.js";
|
||||
@@ -24,25 +21,17 @@ export function registerBrowserInspectCommands(
|
||||
.option("--ref <ref>", "ARIA ref from ai snapshot")
|
||||
.option("--element <selector>", "CSS selector for element screenshot")
|
||||
.option("--type <png|jpeg>", "Output type (default: png)", "png")
|
||||
.option("--filename <name>", "Preferred output filename")
|
||||
.action(async (targetId: string | undefined, opts, cmd) => {
|
||||
const parent = parentOpts(cmd);
|
||||
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||
try {
|
||||
const advanced = Boolean(opts.ref || opts.element || opts.filename);
|
||||
const result = advanced
|
||||
? await browserScreenshotAction(baseUrl, {
|
||||
targetId: targetId?.trim() || undefined,
|
||||
fullPage: Boolean(opts.fullPage),
|
||||
ref: opts.ref?.trim() || undefined,
|
||||
element: opts.element?.trim() || undefined,
|
||||
filename: opts.filename?.trim() || undefined,
|
||||
type: opts.type === "jpeg" ? "jpeg" : "png",
|
||||
})
|
||||
: await browserScreenshot(baseUrl, {
|
||||
targetId: targetId?.trim() || undefined,
|
||||
fullPage: Boolean(opts.fullPage),
|
||||
});
|
||||
const result = await browserScreenshotAction(baseUrl, {
|
||||
targetId: targetId?.trim() || undefined,
|
||||
fullPage: Boolean(opts.fullPage),
|
||||
ref: opts.ref?.trim() || undefined,
|
||||
element: opts.element?.trim() || undefined,
|
||||
type: opts.type === "jpeg" ? "jpeg" : "png",
|
||||
});
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
@@ -54,88 +43,10 @@ export function registerBrowserInspectCommands(
|
||||
}
|
||||
});
|
||||
|
||||
browser
|
||||
.command("query")
|
||||
.description("Query selector matches")
|
||||
.argument("<selector>", "CSS selector")
|
||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||
.option("--limit <n>", "Max matches (default: 20)", (v: string) =>
|
||||
Number(v),
|
||||
)
|
||||
.action(async (selector: string, opts, cmd) => {
|
||||
const parent = parentOpts(cmd);
|
||||
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||
try {
|
||||
const result = await browserQuery(baseUrl, {
|
||||
selector,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
limit: Number.isFinite(opts.limit) ? opts.limit : undefined,
|
||||
});
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
defaultRuntime.log(JSON.stringify(result.matches, null, 2));
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
browser
|
||||
.command("dom")
|
||||
.description("Dump DOM (html or text) with truncation")
|
||||
.option("--format <html|text>", "Output format (default: html)", "html")
|
||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||
.option("--selector <css>", "Optional CSS selector to scope the dump")
|
||||
.option(
|
||||
"--max-chars <n>",
|
||||
"Max characters (default: 200000)",
|
||||
(v: string) => Number(v),
|
||||
)
|
||||
.option("--out <path>", "Write output to a file")
|
||||
.action(async (opts, cmd) => {
|
||||
const parent = parentOpts(cmd);
|
||||
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||
const format = opts.format === "text" ? "text" : "html";
|
||||
try {
|
||||
const result = await browserDom(baseUrl, {
|
||||
format,
|
||||
targetId: opts.targetId?.trim() || undefined,
|
||||
maxChars: Number.isFinite(opts.maxChars) ? opts.maxChars : undefined,
|
||||
selector: opts.selector?.trim() || undefined,
|
||||
});
|
||||
if (opts.out) {
|
||||
const fs = await import("node:fs/promises");
|
||||
await fs.writeFile(opts.out, result.text, "utf8");
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(
|
||||
JSON.stringify({ ok: true, out: opts.out }, null, 2),
|
||||
);
|
||||
} else {
|
||||
defaultRuntime.log(opts.out);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
defaultRuntime.log(result.text);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
browser
|
||||
.command("snapshot")
|
||||
.description("Capture an AI-friendly snapshot (aria, domSnapshot, or ai)")
|
||||
.option(
|
||||
"--format <aria|domSnapshot|ai>",
|
||||
"Snapshot format (default: aria)",
|
||||
"aria",
|
||||
)
|
||||
.description("Capture an AI-friendly snapshot (aria or ai)")
|
||||
.option("--format <aria|ai>", "Snapshot format (default: aria)", "aria")
|
||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||
.option("--limit <n>", "Max nodes (default: 500/800)", (v: string) =>
|
||||
Number(v),
|
||||
@@ -144,12 +55,7 @@ export function registerBrowserInspectCommands(
|
||||
.action(async (opts, cmd) => {
|
||||
const parent = parentOpts(cmd);
|
||||
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||
const format =
|
||||
opts.format === "domSnapshot"
|
||||
? "domSnapshot"
|
||||
: opts.format === "ai"
|
||||
? "ai"
|
||||
: "aria";
|
||||
const format = opts.format === "ai" ? "ai" : "aria";
|
||||
try {
|
||||
const result = await browserSnapshot(baseUrl, {
|
||||
format,
|
||||
@@ -185,11 +91,6 @@ export function registerBrowserInspectCommands(
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.format === "domSnapshot") {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
const nodes = "nodes" in result ? result.nodes : [];
|
||||
defaultRuntime.log(
|
||||
nodes
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
browserTabs,
|
||||
resolveBrowserControlUrl,
|
||||
} from "../browser/client.js";
|
||||
import { browserClosePage } from "../browser/client-actions.js";
|
||||
import { browserAct } from "../browser/client-actions.js";
|
||||
import { danger, info } from "../globals.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import type { BrowserParentOpts } from "./browser-cli-shared.js";
|
||||
@@ -168,7 +168,7 @@ export function registerBrowserManageCommands(
|
||||
if (targetId?.trim()) {
|
||||
await browserCloseTab(baseUrl, targetId.trim());
|
||||
} else {
|
||||
await browserClosePage(baseUrl);
|
||||
await browserAct(baseUrl, { kind: "close" });
|
||||
}
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify({ ok: true }, null, 2));
|
||||
|
||||
Reference in New Issue
Block a user