feat(browser): expand browser control surface
This commit is contained in:
@@ -125,6 +125,7 @@ const BrowserToolSchema = Type.Object({
|
|||||||
compact: Type.Optional(Type.Boolean()),
|
compact: Type.Optional(Type.Boolean()),
|
||||||
depth: Type.Optional(Type.Number()),
|
depth: Type.Optional(Type.Number()),
|
||||||
selector: Type.Optional(Type.String()),
|
selector: Type.Optional(Type.String()),
|
||||||
|
frame: Type.Optional(Type.String()),
|
||||||
fullPage: Type.Optional(Type.Boolean()),
|
fullPage: Type.Optional(Type.Boolean()),
|
||||||
ref: Type.Optional(Type.String()),
|
ref: Type.Optional(Type.String()),
|
||||||
element: Type.Optional(Type.String()),
|
element: Type.Optional(Type.String()),
|
||||||
@@ -354,16 +355,18 @@ export function createBrowserTool(opts?: {
|
|||||||
typeof params.selector === "string"
|
typeof params.selector === "string"
|
||||||
? params.selector.trim()
|
? params.selector.trim()
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const frame =
|
||||||
|
typeof params.frame === "string" ? params.frame.trim() : undefined;
|
||||||
const snapshot = await browserSnapshot(baseUrl, {
|
const snapshot = await browserSnapshot(baseUrl, {
|
||||||
format,
|
format,
|
||||||
targetId,
|
targetId,
|
||||||
limit,
|
limit,
|
||||||
...(resolvedMaxChars ? { maxChars: resolvedMaxChars } : {}),
|
...(resolvedMaxChars ? { maxChars: resolvedMaxChars } : {}),
|
||||||
...(resolvedMaxChars ? { maxChars: resolvedMaxChars } : {}),
|
|
||||||
interactive,
|
interactive,
|
||||||
compact,
|
compact,
|
||||||
depth,
|
depth,
|
||||||
selector,
|
selector,
|
||||||
|
frame,
|
||||||
profile,
|
profile,
|
||||||
});
|
});
|
||||||
if (snapshot.format === "ai") {
|
if (snapshot.format === "ai") {
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export type BrowserActRequest =
|
|||||||
doubleClick?: boolean;
|
doubleClick?: boolean;
|
||||||
button?: string;
|
button?: string;
|
||||||
modifiers?: string[];
|
modifiers?: string[];
|
||||||
|
timeoutMs?: number;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
kind: "type";
|
kind: "type";
|
||||||
@@ -31,15 +32,29 @@ export type BrowserActRequest =
|
|||||||
targetId?: string;
|
targetId?: string;
|
||||||
submit?: boolean;
|
submit?: boolean;
|
||||||
slowly?: boolean;
|
slowly?: boolean;
|
||||||
|
timeoutMs?: number;
|
||||||
|
}
|
||||||
|
| { kind: "press"; key: string; targetId?: string; delayMs?: number }
|
||||||
|
| { kind: "hover"; ref: string; targetId?: string; timeoutMs?: number }
|
||||||
|
| {
|
||||||
|
kind: "drag";
|
||||||
|
startRef: string;
|
||||||
|
endRef: string;
|
||||||
|
targetId?: string;
|
||||||
|
timeoutMs?: number;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: "select";
|
||||||
|
ref: string;
|
||||||
|
values: string[];
|
||||||
|
targetId?: string;
|
||||||
|
timeoutMs?: number;
|
||||||
}
|
}
|
||||||
| { 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";
|
kind: "fill";
|
||||||
fields: BrowserFormField[];
|
fields: BrowserFormField[];
|
||||||
targetId?: string;
|
targetId?: string;
|
||||||
|
timeoutMs?: number;
|
||||||
}
|
}
|
||||||
| { kind: "resize"; width: number; height: number; targetId?: string }
|
| { kind: "resize"; width: number; height: number; targetId?: string }
|
||||||
| {
|
| {
|
||||||
@@ -47,7 +62,12 @@ export type BrowserActRequest =
|
|||||||
timeMs?: number;
|
timeMs?: number;
|
||||||
text?: string;
|
text?: string;
|
||||||
textGone?: string;
|
textGone?: string;
|
||||||
|
selector?: string;
|
||||||
|
url?: string;
|
||||||
|
loadState?: "load" | "domcontentloaded" | "networkidle";
|
||||||
|
fn?: string;
|
||||||
targetId?: string;
|
targetId?: string;
|
||||||
|
timeoutMs?: number;
|
||||||
}
|
}
|
||||||
| { kind: "evaluate"; fn: string; ref?: string; targetId?: string }
|
| { kind: "evaluate"; fn: string; ref?: string; targetId?: string }
|
||||||
| { kind: "close"; targetId?: string };
|
| { kind: "close"; targetId?: string };
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
import type { BrowserActionPathResult } from "./client-actions-types.js";
|
import type {
|
||||||
|
BrowserActionPathResult,
|
||||||
|
BrowserActionTargetOk,
|
||||||
|
} from "./client-actions-types.js";
|
||||||
import { fetchBrowserJson } from "./client-fetch.js";
|
import { fetchBrowserJson } from "./client-fetch.js";
|
||||||
import type { BrowserConsoleMessage } from "./pw-session.js";
|
import type {
|
||||||
|
BrowserConsoleMessage,
|
||||||
|
BrowserNetworkRequest,
|
||||||
|
BrowserPageError,
|
||||||
|
} from "./pw-session.js";
|
||||||
|
|
||||||
function buildProfileQuery(profile?: string): string {
|
function buildProfileQuery(profile?: string): string {
|
||||||
return profile ? `?profile=${encodeURIComponent(profile)}` : "";
|
return profile ? `?profile=${encodeURIComponent(profile)}` : "";
|
||||||
@@ -34,3 +41,100 @@ export async function browserPdfSave(
|
|||||||
timeoutMs: 20000,
|
timeoutMs: 20000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function browserPageErrors(
|
||||||
|
baseUrl: string,
|
||||||
|
opts: { targetId?: string; clear?: boolean; profile?: string } = {},
|
||||||
|
): Promise<{ ok: true; targetId: string; errors: BrowserPageError[] }> {
|
||||||
|
const q = new URLSearchParams();
|
||||||
|
if (opts.targetId) q.set("targetId", opts.targetId);
|
||||||
|
if (typeof opts.clear === "boolean") q.set("clear", String(opts.clear));
|
||||||
|
if (opts.profile) q.set("profile", opts.profile);
|
||||||
|
const suffix = q.toString() ? `?${q.toString()}` : "";
|
||||||
|
return await fetchBrowserJson<{
|
||||||
|
ok: true;
|
||||||
|
targetId: string;
|
||||||
|
errors: BrowserPageError[];
|
||||||
|
}>(`${baseUrl}/errors${suffix}`, { timeoutMs: 20000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function browserRequests(
|
||||||
|
baseUrl: string,
|
||||||
|
opts: {
|
||||||
|
targetId?: string;
|
||||||
|
filter?: string;
|
||||||
|
clear?: boolean;
|
||||||
|
profile?: string;
|
||||||
|
} = {},
|
||||||
|
): Promise<{ ok: true; targetId: string; requests: BrowserNetworkRequest[] }> {
|
||||||
|
const q = new URLSearchParams();
|
||||||
|
if (opts.targetId) q.set("targetId", opts.targetId);
|
||||||
|
if (opts.filter) q.set("filter", opts.filter);
|
||||||
|
if (typeof opts.clear === "boolean") q.set("clear", String(opts.clear));
|
||||||
|
if (opts.profile) q.set("profile", opts.profile);
|
||||||
|
const suffix = q.toString() ? `?${q.toString()}` : "";
|
||||||
|
return await fetchBrowserJson<{
|
||||||
|
ok: true;
|
||||||
|
targetId: string;
|
||||||
|
requests: BrowserNetworkRequest[];
|
||||||
|
}>(`${baseUrl}/requests${suffix}`, { timeoutMs: 20000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function browserTraceStart(
|
||||||
|
baseUrl: string,
|
||||||
|
opts: {
|
||||||
|
targetId?: string;
|
||||||
|
screenshots?: boolean;
|
||||||
|
snapshots?: boolean;
|
||||||
|
sources?: boolean;
|
||||||
|
profile?: string;
|
||||||
|
} = {},
|
||||||
|
): Promise<BrowserActionTargetOk> {
|
||||||
|
const q = buildProfileQuery(opts.profile);
|
||||||
|
return await fetchBrowserJson<BrowserActionTargetOk>(
|
||||||
|
`${baseUrl}/trace/start${q}`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
targetId: opts.targetId,
|
||||||
|
screenshots: opts.screenshots,
|
||||||
|
snapshots: opts.snapshots,
|
||||||
|
sources: opts.sources,
|
||||||
|
}),
|
||||||
|
timeoutMs: 20000,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function browserTraceStop(
|
||||||
|
baseUrl: string,
|
||||||
|
opts: { targetId?: string; path?: string; profile?: string } = {},
|
||||||
|
): Promise<BrowserActionPathResult> {
|
||||||
|
const q = buildProfileQuery(opts.profile);
|
||||||
|
return await fetchBrowserJson<BrowserActionPathResult>(
|
||||||
|
`${baseUrl}/trace/stop${q}`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ targetId: opts.targetId, path: opts.path }),
|
||||||
|
timeoutMs: 20000,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function browserHighlight(
|
||||||
|
baseUrl: string,
|
||||||
|
opts: { ref: string; targetId?: string; profile?: string },
|
||||||
|
): Promise<BrowserActionTargetOk> {
|
||||||
|
const q = buildProfileQuery(opts.profile);
|
||||||
|
return await fetchBrowserJson<BrowserActionTargetOk>(
|
||||||
|
`${baseUrl}/highlight${q}`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ targetId: opts.targetId, ref: opts.ref }),
|
||||||
|
timeoutMs: 20000,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
307
src/browser/client-actions-state.ts
Normal file
307
src/browser/client-actions-state.ts
Normal file
@@ -0,0 +1,307 @@
|
|||||||
|
import type {
|
||||||
|
BrowserActionOk,
|
||||||
|
BrowserActionTargetOk,
|
||||||
|
} from "./client-actions-types.js";
|
||||||
|
import { fetchBrowserJson } from "./client-fetch.js";
|
||||||
|
|
||||||
|
function buildProfileQuery(profile?: string): string {
|
||||||
|
return profile ? `?profile=${encodeURIComponent(profile)}` : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function browserCookies(
|
||||||
|
baseUrl: string,
|
||||||
|
opts: { targetId?: string; profile?: string } = {},
|
||||||
|
): Promise<{ ok: true; targetId: string; cookies: unknown[] }> {
|
||||||
|
const q = new URLSearchParams();
|
||||||
|
if (opts.targetId) q.set("targetId", opts.targetId);
|
||||||
|
if (opts.profile) q.set("profile", opts.profile);
|
||||||
|
const suffix = q.toString() ? `?${q.toString()}` : "";
|
||||||
|
return await fetchBrowserJson<{
|
||||||
|
ok: true;
|
||||||
|
targetId: string;
|
||||||
|
cookies: unknown[];
|
||||||
|
}>(`${baseUrl}/cookies${suffix}`, { timeoutMs: 20000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function browserCookiesSet(
|
||||||
|
baseUrl: string,
|
||||||
|
opts: {
|
||||||
|
cookie: Record<string, unknown>;
|
||||||
|
targetId?: string;
|
||||||
|
profile?: string;
|
||||||
|
},
|
||||||
|
): Promise<BrowserActionTargetOk> {
|
||||||
|
const q = buildProfileQuery(opts.profile);
|
||||||
|
return await fetchBrowserJson<BrowserActionTargetOk>(
|
||||||
|
`${baseUrl}/cookies/set${q}`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ targetId: opts.targetId, cookie: opts.cookie }),
|
||||||
|
timeoutMs: 20000,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function browserCookiesClear(
|
||||||
|
baseUrl: string,
|
||||||
|
opts: { targetId?: string; profile?: string } = {},
|
||||||
|
): Promise<BrowserActionTargetOk> {
|
||||||
|
const q = buildProfileQuery(opts.profile);
|
||||||
|
return await fetchBrowserJson<BrowserActionTargetOk>(
|
||||||
|
`${baseUrl}/cookies/clear${q}`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ targetId: opts.targetId }),
|
||||||
|
timeoutMs: 20000,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function browserStorageGet(
|
||||||
|
baseUrl: string,
|
||||||
|
opts: {
|
||||||
|
kind: "local" | "session";
|
||||||
|
key?: string;
|
||||||
|
targetId?: string;
|
||||||
|
profile?: string;
|
||||||
|
},
|
||||||
|
): Promise<{ ok: true; targetId: string; values: Record<string, string> }> {
|
||||||
|
const q = new URLSearchParams();
|
||||||
|
if (opts.targetId) q.set("targetId", opts.targetId);
|
||||||
|
if (opts.key) q.set("key", opts.key);
|
||||||
|
if (opts.profile) q.set("profile", opts.profile);
|
||||||
|
const suffix = q.toString() ? `?${q.toString()}` : "";
|
||||||
|
return await fetchBrowserJson<{
|
||||||
|
ok: true;
|
||||||
|
targetId: string;
|
||||||
|
values: Record<string, string>;
|
||||||
|
}>(`${baseUrl}/storage/${opts.kind}${suffix}`, { timeoutMs: 20000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function browserStorageSet(
|
||||||
|
baseUrl: string,
|
||||||
|
opts: {
|
||||||
|
kind: "local" | "session";
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
targetId?: string;
|
||||||
|
profile?: string;
|
||||||
|
},
|
||||||
|
): Promise<BrowserActionTargetOk> {
|
||||||
|
const q = buildProfileQuery(opts.profile);
|
||||||
|
return await fetchBrowserJson<BrowserActionTargetOk>(
|
||||||
|
`${baseUrl}/storage/${opts.kind}/set${q}`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
targetId: opts.targetId,
|
||||||
|
key: opts.key,
|
||||||
|
value: opts.value,
|
||||||
|
}),
|
||||||
|
timeoutMs: 20000,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function browserStorageClear(
|
||||||
|
baseUrl: string,
|
||||||
|
opts: { kind: "local" | "session"; targetId?: string; profile?: string },
|
||||||
|
): Promise<BrowserActionTargetOk> {
|
||||||
|
const q = buildProfileQuery(opts.profile);
|
||||||
|
return await fetchBrowserJson<BrowserActionTargetOk>(
|
||||||
|
`${baseUrl}/storage/${opts.kind}/clear${q}`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ targetId: opts.targetId }),
|
||||||
|
timeoutMs: 20000,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function browserSetOffline(
|
||||||
|
baseUrl: string,
|
||||||
|
opts: { offline: boolean; targetId?: string; profile?: string },
|
||||||
|
): Promise<BrowserActionTargetOk> {
|
||||||
|
const q = buildProfileQuery(opts.profile);
|
||||||
|
return await fetchBrowserJson<BrowserActionTargetOk>(
|
||||||
|
`${baseUrl}/set/offline${q}`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ targetId: opts.targetId, offline: opts.offline }),
|
||||||
|
timeoutMs: 20000,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function browserSetHeaders(
|
||||||
|
baseUrl: string,
|
||||||
|
opts: {
|
||||||
|
headers: Record<string, string>;
|
||||||
|
targetId?: string;
|
||||||
|
profile?: string;
|
||||||
|
},
|
||||||
|
): Promise<BrowserActionTargetOk> {
|
||||||
|
const q = buildProfileQuery(opts.profile);
|
||||||
|
return await fetchBrowserJson<BrowserActionTargetOk>(
|
||||||
|
`${baseUrl}/set/headers${q}`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ targetId: opts.targetId, headers: opts.headers }),
|
||||||
|
timeoutMs: 20000,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function browserSetHttpCredentials(
|
||||||
|
baseUrl: string,
|
||||||
|
opts: {
|
||||||
|
username?: string;
|
||||||
|
password?: string;
|
||||||
|
clear?: boolean;
|
||||||
|
targetId?: string;
|
||||||
|
profile?: string;
|
||||||
|
} = {},
|
||||||
|
): Promise<BrowserActionTargetOk> {
|
||||||
|
const q = buildProfileQuery(opts.profile);
|
||||||
|
return await fetchBrowserJson<BrowserActionTargetOk>(
|
||||||
|
`${baseUrl}/set/credentials${q}`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
targetId: opts.targetId,
|
||||||
|
username: opts.username,
|
||||||
|
password: opts.password,
|
||||||
|
clear: opts.clear,
|
||||||
|
}),
|
||||||
|
timeoutMs: 20000,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function browserSetGeolocation(
|
||||||
|
baseUrl: string,
|
||||||
|
opts: {
|
||||||
|
latitude?: number;
|
||||||
|
longitude?: number;
|
||||||
|
accuracy?: number;
|
||||||
|
origin?: string;
|
||||||
|
clear?: boolean;
|
||||||
|
targetId?: string;
|
||||||
|
profile?: string;
|
||||||
|
} = {},
|
||||||
|
): Promise<BrowserActionTargetOk> {
|
||||||
|
const q = buildProfileQuery(opts.profile);
|
||||||
|
return await fetchBrowserJson<BrowserActionTargetOk>(
|
||||||
|
`${baseUrl}/set/geolocation${q}`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
targetId: opts.targetId,
|
||||||
|
latitude: opts.latitude,
|
||||||
|
longitude: opts.longitude,
|
||||||
|
accuracy: opts.accuracy,
|
||||||
|
origin: opts.origin,
|
||||||
|
clear: opts.clear,
|
||||||
|
}),
|
||||||
|
timeoutMs: 20000,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function browserSetMedia(
|
||||||
|
baseUrl: string,
|
||||||
|
opts: {
|
||||||
|
colorScheme: "dark" | "light" | "no-preference" | "none";
|
||||||
|
targetId?: string;
|
||||||
|
profile?: string;
|
||||||
|
},
|
||||||
|
): Promise<BrowserActionTargetOk> {
|
||||||
|
const q = buildProfileQuery(opts.profile);
|
||||||
|
return await fetchBrowserJson<BrowserActionTargetOk>(
|
||||||
|
`${baseUrl}/set/media${q}`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
targetId: opts.targetId,
|
||||||
|
colorScheme: opts.colorScheme,
|
||||||
|
}),
|
||||||
|
timeoutMs: 20000,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function browserSetTimezone(
|
||||||
|
baseUrl: string,
|
||||||
|
opts: { timezoneId: string; targetId?: string; profile?: string },
|
||||||
|
): Promise<BrowserActionTargetOk> {
|
||||||
|
const q = buildProfileQuery(opts.profile);
|
||||||
|
return await fetchBrowserJson<BrowserActionTargetOk>(
|
||||||
|
`${baseUrl}/set/timezone${q}`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
targetId: opts.targetId,
|
||||||
|
timezoneId: opts.timezoneId,
|
||||||
|
}),
|
||||||
|
timeoutMs: 20000,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function browserSetLocale(
|
||||||
|
baseUrl: string,
|
||||||
|
opts: { locale: string; targetId?: string; profile?: string },
|
||||||
|
): Promise<BrowserActionTargetOk> {
|
||||||
|
const q = buildProfileQuery(opts.profile);
|
||||||
|
return await fetchBrowserJson<BrowserActionTargetOk>(
|
||||||
|
`${baseUrl}/set/locale${q}`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ targetId: opts.targetId, locale: opts.locale }),
|
||||||
|
timeoutMs: 20000,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function browserSetDevice(
|
||||||
|
baseUrl: string,
|
||||||
|
opts: { name: string; targetId?: string; profile?: string },
|
||||||
|
): Promise<BrowserActionTargetOk> {
|
||||||
|
const q = buildProfileQuery(opts.profile);
|
||||||
|
return await fetchBrowserJson<BrowserActionTargetOk>(
|
||||||
|
`${baseUrl}/set/device${q}`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ targetId: opts.targetId, name: opts.name }),
|
||||||
|
timeoutMs: 20000,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function browserClearPermissions(
|
||||||
|
baseUrl: string,
|
||||||
|
opts: { targetId?: string; profile?: string } = {},
|
||||||
|
): Promise<BrowserActionOk> {
|
||||||
|
const q = buildProfileQuery(opts.profile);
|
||||||
|
return await fetchBrowserJson<BrowserActionOk>(
|
||||||
|
`${baseUrl}/set/geolocation${q}`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ targetId: opts.targetId, clear: true }),
|
||||||
|
timeoutMs: 20000,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,3 +12,5 @@ export type BrowserActionPathResult = {
|
|||||||
targetId: string;
|
targetId: string;
|
||||||
url?: string;
|
url?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type BrowserActionTargetOk = { ok: true; targetId: string };
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
export * from "./client-actions-core.js";
|
export * from "./client-actions-core.js";
|
||||||
export * from "./client-actions-observe.js";
|
export * from "./client-actions-observe.js";
|
||||||
|
export * from "./client-actions-state.js";
|
||||||
export * from "./client-actions-types.js";
|
export * from "./client-actions-types.js";
|
||||||
|
|||||||
@@ -72,6 +72,13 @@ export type SnapshotResult =
|
|||||||
url: string;
|
url: string;
|
||||||
snapshot: string;
|
snapshot: string;
|
||||||
truncated?: boolean;
|
truncated?: boolean;
|
||||||
|
refs?: Record<string, { role: string; name?: string; nth?: number }>;
|
||||||
|
stats?: {
|
||||||
|
lines: number;
|
||||||
|
chars: number;
|
||||||
|
refs: number;
|
||||||
|
interactive: number;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export function resolveBrowserControlUrl(overrideUrl?: string) {
|
export function resolveBrowserControlUrl(overrideUrl?: string) {
|
||||||
@@ -243,6 +250,26 @@ export async function browserCloseTab(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function browserTabAction(
|
||||||
|
baseUrl: string,
|
||||||
|
opts: {
|
||||||
|
action: "list" | "new" | "close" | "select";
|
||||||
|
index?: number;
|
||||||
|
profile?: string;
|
||||||
|
},
|
||||||
|
): Promise<unknown> {
|
||||||
|
const q = buildProfileQuery(opts.profile);
|
||||||
|
return await fetchBrowserJson(`${baseUrl}/tabs/action${q}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
action: opts.action,
|
||||||
|
index: opts.index,
|
||||||
|
}),
|
||||||
|
timeoutMs: 10_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function browserSnapshot(
|
export async function browserSnapshot(
|
||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
opts: {
|
opts: {
|
||||||
@@ -254,6 +281,7 @@ export async function browserSnapshot(
|
|||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
depth?: number;
|
depth?: number;
|
||||||
selector?: string;
|
selector?: string;
|
||||||
|
frame?: string;
|
||||||
profile?: string;
|
profile?: string;
|
||||||
},
|
},
|
||||||
): Promise<SnapshotResult> {
|
): Promise<SnapshotResult> {
|
||||||
@@ -270,6 +298,7 @@ export async function browserSnapshot(
|
|||||||
if (typeof opts.depth === "number" && Number.isFinite(opts.depth))
|
if (typeof opts.depth === "number" && Number.isFinite(opts.depth))
|
||||||
q.set("depth", String(opts.depth));
|
q.set("depth", String(opts.depth));
|
||||||
if (opts.selector?.trim()) q.set("selector", opts.selector.trim());
|
if (opts.selector?.trim()) q.set("selector", opts.selector.trim());
|
||||||
|
if (opts.frame?.trim()) q.set("frame", opts.frame.trim());
|
||||||
if (opts.profile) q.set("profile", opts.profile);
|
if (opts.profile) q.set("profile", opts.profile);
|
||||||
return await fetchBrowserJson<SnapshotResult>(
|
return await fetchBrowserJson<SnapshotResult>(
|
||||||
`${baseUrl}/snapshot?${q.toString()}`,
|
`${baseUrl}/snapshot?${q.toString()}`,
|
||||||
|
|||||||
@@ -12,20 +12,39 @@ export {
|
|||||||
armFileUploadViaPlaywright,
|
armFileUploadViaPlaywright,
|
||||||
clickViaPlaywright,
|
clickViaPlaywright,
|
||||||
closePageViaPlaywright,
|
closePageViaPlaywright,
|
||||||
|
cookiesClearViaPlaywright,
|
||||||
|
cookiesGetViaPlaywright,
|
||||||
|
cookiesSetViaPlaywright,
|
||||||
dragViaPlaywright,
|
dragViaPlaywright,
|
||||||
|
emulateMediaViaPlaywright,
|
||||||
evaluateViaPlaywright,
|
evaluateViaPlaywright,
|
||||||
fillFormViaPlaywright,
|
fillFormViaPlaywright,
|
||||||
getConsoleMessagesViaPlaywright,
|
getConsoleMessagesViaPlaywright,
|
||||||
|
getNetworkRequestsViaPlaywright,
|
||||||
|
getPageErrorsViaPlaywright,
|
||||||
|
highlightViaPlaywright,
|
||||||
hoverViaPlaywright,
|
hoverViaPlaywright,
|
||||||
navigateViaPlaywright,
|
navigateViaPlaywright,
|
||||||
pdfViaPlaywright,
|
pdfViaPlaywright,
|
||||||
pressKeyViaPlaywright,
|
pressKeyViaPlaywright,
|
||||||
resizeViewportViaPlaywright,
|
resizeViewportViaPlaywright,
|
||||||
selectOptionViaPlaywright,
|
selectOptionViaPlaywright,
|
||||||
|
setDeviceViaPlaywright,
|
||||||
|
setExtraHTTPHeadersViaPlaywright,
|
||||||
|
setGeolocationViaPlaywright,
|
||||||
|
setHttpCredentialsViaPlaywright,
|
||||||
setInputFilesViaPlaywright,
|
setInputFilesViaPlaywright,
|
||||||
|
setLocaleViaPlaywright,
|
||||||
|
setOfflineViaPlaywright,
|
||||||
|
setTimezoneViaPlaywright,
|
||||||
snapshotAiViaPlaywright,
|
snapshotAiViaPlaywright,
|
||||||
snapshotRoleViaPlaywright,
|
snapshotRoleViaPlaywright,
|
||||||
|
storageClearViaPlaywright,
|
||||||
|
storageGetViaPlaywright,
|
||||||
|
storageSetViaPlaywright,
|
||||||
takeScreenshotViaPlaywright,
|
takeScreenshotViaPlaywright,
|
||||||
|
traceStartViaPlaywright,
|
||||||
|
traceStopViaPlaywright,
|
||||||
typeViaPlaywright,
|
typeViaPlaywright,
|
||||||
waitForViaPlaywright,
|
waitForViaPlaywright,
|
||||||
} from "./pw-tools-core.js";
|
} from "./pw-tools-core.js";
|
||||||
|
|||||||
@@ -7,6 +7,13 @@ export type RoleRef = {
|
|||||||
|
|
||||||
export type RoleRefMap = Record<string, RoleRef>;
|
export type RoleRefMap = Record<string, RoleRef>;
|
||||||
|
|
||||||
|
export type RoleSnapshotStats = {
|
||||||
|
lines: number;
|
||||||
|
chars: number;
|
||||||
|
refs: number;
|
||||||
|
interactive: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type RoleSnapshotOptions = {
|
export type RoleSnapshotOptions = {
|
||||||
/** Only include interactive elements (buttons, links, inputs, etc.). */
|
/** Only include interactive elements (buttons, links, inputs, etc.). */
|
||||||
interactive?: boolean;
|
interactive?: boolean;
|
||||||
@@ -70,6 +77,21 @@ const STRUCTURAL_ROLES = new Set([
|
|||||||
"none",
|
"none",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
export function getRoleSnapshotStats(
|
||||||
|
snapshot: string,
|
||||||
|
refs: RoleRefMap,
|
||||||
|
): RoleSnapshotStats {
|
||||||
|
const interactive = Object.values(refs).filter((r) =>
|
||||||
|
INTERACTIVE_ROLES.has(r.role),
|
||||||
|
).length;
|
||||||
|
return {
|
||||||
|
lines: snapshot.split("\n").length,
|
||||||
|
chars: snapshot.length,
|
||||||
|
refs: Object.keys(refs).length,
|
||||||
|
interactive,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function getIndentLevel(line: string): number {
|
function getIndentLevel(line: string): number {
|
||||||
const match = line.match(/^(\s*)/);
|
const match = line.match(/^(\s*)/);
|
||||||
return match ? Math.floor(match[1].length / 2) : 0;
|
return match ? Math.floor(match[1].length / 2) : 0;
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import type {
|
|||||||
BrowserContext,
|
BrowserContext,
|
||||||
ConsoleMessage,
|
ConsoleMessage,
|
||||||
Page,
|
Page,
|
||||||
|
Request,
|
||||||
|
Response,
|
||||||
} from "playwright-core";
|
} from "playwright-core";
|
||||||
import { chromium } from "playwright-core";
|
import { chromium } from "playwright-core";
|
||||||
import { formatErrorMessage } from "../infra/errors.js";
|
import { formatErrorMessage } from "../infra/errors.js";
|
||||||
@@ -15,6 +17,24 @@ export type BrowserConsoleMessage = {
|
|||||||
location?: { url?: string; lineNumber?: number; columnNumber?: number };
|
location?: { url?: string; lineNumber?: number; columnNumber?: number };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type BrowserPageError = {
|
||||||
|
message: string;
|
||||||
|
name?: string;
|
||||||
|
stack?: string;
|
||||||
|
timestamp: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BrowserNetworkRequest = {
|
||||||
|
id: string;
|
||||||
|
timestamp: string;
|
||||||
|
method: string;
|
||||||
|
url: string;
|
||||||
|
resourceType?: string;
|
||||||
|
status?: number;
|
||||||
|
ok?: boolean;
|
||||||
|
failureText?: string;
|
||||||
|
};
|
||||||
|
|
||||||
type SnapshotForAIResult = { full: string; incremental?: string };
|
type SnapshotForAIResult = { full: string; incremental?: string };
|
||||||
type SnapshotForAIOptions = { timeout?: number; track?: string };
|
type SnapshotForAIOptions = { timeout?: number; track?: string };
|
||||||
|
|
||||||
@@ -37,6 +57,10 @@ type ConnectedBrowser = {
|
|||||||
|
|
||||||
type PageState = {
|
type PageState = {
|
||||||
console: BrowserConsoleMessage[];
|
console: BrowserConsoleMessage[];
|
||||||
|
errors: BrowserPageError[];
|
||||||
|
requests: BrowserNetworkRequest[];
|
||||||
|
requestIds: WeakMap<Request, string>;
|
||||||
|
nextRequestId: number;
|
||||||
armIdUpload: number;
|
armIdUpload: number;
|
||||||
armIdDialog: number;
|
armIdDialog: number;
|
||||||
/**
|
/**
|
||||||
@@ -44,13 +68,21 @@ type PageState = {
|
|||||||
* These refs are NOT Playwright's `aria-ref` values.
|
* These refs are NOT Playwright's `aria-ref` values.
|
||||||
*/
|
*/
|
||||||
roleRefs?: Record<string, { role: string; name?: string; nth?: number }>;
|
roleRefs?: Record<string, { role: string; name?: string; nth?: number }>;
|
||||||
|
roleRefsFrameSelector?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ContextState = {
|
||||||
|
traceActive: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const pageStates = new WeakMap<Page, PageState>();
|
const pageStates = new WeakMap<Page, PageState>();
|
||||||
|
const contextStates = new WeakMap<BrowserContext, ContextState>();
|
||||||
const observedContexts = new WeakSet<BrowserContext>();
|
const observedContexts = new WeakSet<BrowserContext>();
|
||||||
const observedPages = new WeakSet<Page>();
|
const observedPages = new WeakSet<Page>();
|
||||||
|
|
||||||
const MAX_CONSOLE_MESSAGES = 500;
|
const MAX_CONSOLE_MESSAGES = 500;
|
||||||
|
const MAX_PAGE_ERRORS = 200;
|
||||||
|
const MAX_NETWORK_REQUESTS = 500;
|
||||||
|
|
||||||
let cached: ConnectedBrowser | null = null;
|
let cached: ConnectedBrowser | null = null;
|
||||||
let connecting: Promise<ConnectedBrowser> | null = null;
|
let connecting: Promise<ConnectedBrowser> | null = null;
|
||||||
@@ -65,6 +97,10 @@ export function ensurePageState(page: Page): PageState {
|
|||||||
|
|
||||||
const state: PageState = {
|
const state: PageState = {
|
||||||
console: [],
|
console: [],
|
||||||
|
errors: [],
|
||||||
|
requests: [],
|
||||||
|
requestIds: new WeakMap(),
|
||||||
|
nextRequestId: 0,
|
||||||
armIdUpload: 0,
|
armIdUpload: 0,
|
||||||
armIdDialog: 0,
|
armIdDialog: 0,
|
||||||
};
|
};
|
||||||
@@ -82,6 +118,59 @@ export function ensurePageState(page: Page): PageState {
|
|||||||
state.console.push(entry);
|
state.console.push(entry);
|
||||||
if (state.console.length > MAX_CONSOLE_MESSAGES) state.console.shift();
|
if (state.console.length > MAX_CONSOLE_MESSAGES) state.console.shift();
|
||||||
});
|
});
|
||||||
|
page.on("pageerror", (err: Error) => {
|
||||||
|
state.errors.push({
|
||||||
|
message: err?.message ? String(err.message) : String(err),
|
||||||
|
name: err?.name ? String(err.name) : undefined,
|
||||||
|
stack: err?.stack ? String(err.stack) : undefined,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
if (state.errors.length > MAX_PAGE_ERRORS) state.errors.shift();
|
||||||
|
});
|
||||||
|
page.on("request", (req: Request) => {
|
||||||
|
state.nextRequestId += 1;
|
||||||
|
const id = `r${state.nextRequestId}`;
|
||||||
|
state.requestIds.set(req, id);
|
||||||
|
state.requests.push({
|
||||||
|
id,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
method: req.method(),
|
||||||
|
url: req.url(),
|
||||||
|
resourceType: req.resourceType(),
|
||||||
|
});
|
||||||
|
if (state.requests.length > MAX_NETWORK_REQUESTS) state.requests.shift();
|
||||||
|
});
|
||||||
|
page.on("response", (resp: Response) => {
|
||||||
|
const req = resp.request();
|
||||||
|
const id = state.requestIds.get(req);
|
||||||
|
if (!id) return;
|
||||||
|
let rec: BrowserNetworkRequest | undefined;
|
||||||
|
for (let i = state.requests.length - 1; i >= 0; i -= 1) {
|
||||||
|
const candidate = state.requests[i];
|
||||||
|
if (candidate && candidate.id === id) {
|
||||||
|
rec = candidate;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!rec) return;
|
||||||
|
rec.status = resp.status();
|
||||||
|
rec.ok = resp.ok();
|
||||||
|
});
|
||||||
|
page.on("requestfailed", (req: Request) => {
|
||||||
|
const id = state.requestIds.get(req);
|
||||||
|
if (!id) return;
|
||||||
|
let rec: BrowserNetworkRequest | undefined;
|
||||||
|
for (let i = state.requests.length - 1; i >= 0; i -= 1) {
|
||||||
|
const candidate = state.requests[i];
|
||||||
|
if (candidate && candidate.id === id) {
|
||||||
|
rec = candidate;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!rec) return;
|
||||||
|
rec.failureText = req.failure()?.errorText;
|
||||||
|
rec.ok = false;
|
||||||
|
});
|
||||||
page.on("close", () => {
|
page.on("close", () => {
|
||||||
pageStates.delete(page);
|
pageStates.delete(page);
|
||||||
observedPages.delete(page);
|
observedPages.delete(page);
|
||||||
@@ -94,11 +183,20 @@ export function ensurePageState(page: Page): PageState {
|
|||||||
function observeContext(context: BrowserContext) {
|
function observeContext(context: BrowserContext) {
|
||||||
if (observedContexts.has(context)) return;
|
if (observedContexts.has(context)) return;
|
||||||
observedContexts.add(context);
|
observedContexts.add(context);
|
||||||
|
ensureContextState(context);
|
||||||
|
|
||||||
for (const page of context.pages()) ensurePageState(page);
|
for (const page of context.pages()) ensurePageState(page);
|
||||||
context.on("page", (page) => ensurePageState(page));
|
context.on("page", (page) => ensurePageState(page));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ensureContextState(context: BrowserContext): ContextState {
|
||||||
|
const existing = contextStates.get(context);
|
||||||
|
if (existing) return existing;
|
||||||
|
const state: ContextState = { traceActive: false };
|
||||||
|
contextStates.set(context, state);
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
function observeBrowser(browser: Browser) {
|
function observeBrowser(browser: Browser) {
|
||||||
for (const context of browser.contexts()) observeContext(context);
|
for (const context of browser.contexts()) observeContext(context);
|
||||||
}
|
}
|
||||||
@@ -208,9 +306,18 @@ export function refLocator(page: Page, ref: string) {
|
|||||||
`Unknown ref "${normalized}". Run a new snapshot and use a ref from that snapshot.`,
|
`Unknown ref "${normalized}". Run a new snapshot and use a ref from that snapshot.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
const scope = state?.roleRefsFrameSelector
|
||||||
|
? page.frameLocator(state.roleRefsFrameSelector)
|
||||||
|
: page;
|
||||||
|
const locAny = scope as unknown as {
|
||||||
|
getByRole: (
|
||||||
|
role: never,
|
||||||
|
opts?: { name?: string; exact?: boolean },
|
||||||
|
) => ReturnType<Page["getByRole"]>;
|
||||||
|
};
|
||||||
const locator = info.name
|
const locator = info.name
|
||||||
? page.getByRole(info.role as never, { name: info.name, exact: true })
|
? locAny.getByRole(info.role as never, { name: info.name, exact: true })
|
||||||
: page.getByRole(info.role as never);
|
: locAny.getByRole(info.role as never);
|
||||||
return info.nth !== undefined ? locator.nth(info.nth) : locator;
|
return info.nth !== undefined ? locator.nth(info.nth) : locator;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
|
import type { CDPSession, Page } from "playwright-core";
|
||||||
|
import { devices as playwrightDevices } from "playwright-core";
|
||||||
import type { BrowserFormField } from "./client-actions-core.js";
|
import type { BrowserFormField } from "./client-actions-core.js";
|
||||||
import {
|
import {
|
||||||
buildRoleSnapshotFromAriaSnapshot,
|
buildRoleSnapshotFromAriaSnapshot,
|
||||||
|
getRoleSnapshotStats,
|
||||||
parseRoleRef,
|
parseRoleRef,
|
||||||
type RoleSnapshotOptions,
|
type RoleSnapshotOptions,
|
||||||
} from "./pw-role-snapshot.js";
|
} from "./pw-role-snapshot.js";
|
||||||
import {
|
import {
|
||||||
type BrowserConsoleMessage,
|
type BrowserConsoleMessage,
|
||||||
|
type BrowserNetworkRequest,
|
||||||
|
type BrowserPageError,
|
||||||
|
ensureContextState,
|
||||||
ensurePageState,
|
ensurePageState,
|
||||||
getPageForTargetId,
|
getPageForTargetId,
|
||||||
refLocator,
|
refLocator,
|
||||||
@@ -23,6 +29,42 @@ function requireRef(value: unknown): string {
|
|||||||
return ref;
|
return ref;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toAIFriendlyError(error: unknown, selector: string): Error {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
|
||||||
|
if (message.includes("strict mode violation")) {
|
||||||
|
const countMatch = message.match(/resolved to (\d+) elements/);
|
||||||
|
const count = countMatch ? countMatch[1] : "multiple";
|
||||||
|
return new Error(
|
||||||
|
`Selector "${selector}" matched ${count} elements. ` +
|
||||||
|
`Run a new snapshot to get updated refs, or use a different ref.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
(message.includes("Timeout") || message.includes("waiting for")) &&
|
||||||
|
(message.includes("to be visible") || message.includes("not visible"))
|
||||||
|
) {
|
||||||
|
return new Error(
|
||||||
|
`Element "${selector}" not found or not visible. ` +
|
||||||
|
`Run a new snapshot to see current page elements.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
message.includes("intercepts pointer events") ||
|
||||||
|
message.includes("not visible") ||
|
||||||
|
message.includes("not receive pointer events")
|
||||||
|
) {
|
||||||
|
return new Error(
|
||||||
|
`Element "${selector}" is not interactable (hidden or covered). ` +
|
||||||
|
`Try scrolling it into view, closing overlays, or re-snapshotting.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return error instanceof Error ? error : new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
export async function snapshotAiViaPlaywright(opts: {
|
export async function snapshotAiViaPlaywright(opts: {
|
||||||
cdpUrl: string;
|
cdpUrl: string;
|
||||||
targetId?: string;
|
targetId?: string;
|
||||||
@@ -66,17 +108,28 @@ export async function snapshotRoleViaPlaywright(opts: {
|
|||||||
cdpUrl: string;
|
cdpUrl: string;
|
||||||
targetId?: string;
|
targetId?: string;
|
||||||
selector?: string;
|
selector?: string;
|
||||||
|
frameSelector?: string;
|
||||||
options?: RoleSnapshotOptions;
|
options?: RoleSnapshotOptions;
|
||||||
}): Promise<{ snapshot: string }> {
|
}): Promise<{
|
||||||
|
snapshot: string;
|
||||||
|
refs: Record<string, { role: string; name?: string; nth?: number }>;
|
||||||
|
stats: { lines: number; chars: number; refs: number; interactive: number };
|
||||||
|
}> {
|
||||||
const page = await getPageForTargetId({
|
const page = await getPageForTargetId({
|
||||||
cdpUrl: opts.cdpUrl,
|
cdpUrl: opts.cdpUrl,
|
||||||
targetId: opts.targetId,
|
targetId: opts.targetId,
|
||||||
});
|
});
|
||||||
const state = ensurePageState(page);
|
const state = ensurePageState(page);
|
||||||
|
|
||||||
const locator = opts.selector?.trim()
|
const frameSelector = opts.frameSelector?.trim() || "";
|
||||||
? page.locator(opts.selector.trim())
|
const selector = opts.selector?.trim() || "";
|
||||||
: page.locator(":root");
|
const locator = frameSelector
|
||||||
|
? selector
|
||||||
|
? page.frameLocator(frameSelector).locator(selector)
|
||||||
|
: page.frameLocator(frameSelector).locator(":root")
|
||||||
|
: selector
|
||||||
|
? page.locator(selector)
|
||||||
|
: page.locator(":root");
|
||||||
|
|
||||||
const ariaSnapshot = await locator.ariaSnapshot();
|
const ariaSnapshot = await locator.ariaSnapshot();
|
||||||
const built = buildRoleSnapshotFromAriaSnapshot(
|
const built = buildRoleSnapshotFromAriaSnapshot(
|
||||||
@@ -84,7 +137,95 @@ export async function snapshotRoleViaPlaywright(opts: {
|
|||||||
opts.options,
|
opts.options,
|
||||||
);
|
);
|
||||||
state.roleRefs = built.refs;
|
state.roleRefs = built.refs;
|
||||||
return { snapshot: built.snapshot };
|
state.roleRefsFrameSelector = frameSelector || undefined;
|
||||||
|
return {
|
||||||
|
snapshot: built.snapshot,
|
||||||
|
refs: built.refs,
|
||||||
|
stats: getRoleSnapshotStats(built.snapshot, built.refs),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPageErrorsViaPlaywright(opts: {
|
||||||
|
cdpUrl: string;
|
||||||
|
targetId?: string;
|
||||||
|
clear?: boolean;
|
||||||
|
}): Promise<{ errors: BrowserPageError[] }> {
|
||||||
|
const page = await getPageForTargetId(opts);
|
||||||
|
const state = ensurePageState(page);
|
||||||
|
const errors = [...state.errors];
|
||||||
|
if (opts.clear) state.errors = [];
|
||||||
|
return { errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getNetworkRequestsViaPlaywright(opts: {
|
||||||
|
cdpUrl: string;
|
||||||
|
targetId?: string;
|
||||||
|
filter?: string;
|
||||||
|
clear?: boolean;
|
||||||
|
}): Promise<{ requests: BrowserNetworkRequest[] }> {
|
||||||
|
const page = await getPageForTargetId(opts);
|
||||||
|
const state = ensurePageState(page);
|
||||||
|
const raw = [...state.requests];
|
||||||
|
const filter = typeof opts.filter === "string" ? opts.filter.trim() : "";
|
||||||
|
const requests = filter ? raw.filter((r) => r.url.includes(filter)) : raw;
|
||||||
|
if (opts.clear) {
|
||||||
|
state.requests = [];
|
||||||
|
state.requestIds = new WeakMap();
|
||||||
|
}
|
||||||
|
return { requests };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function highlightViaPlaywright(opts: {
|
||||||
|
cdpUrl: string;
|
||||||
|
targetId?: string;
|
||||||
|
ref: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
const page = await getPageForTargetId(opts);
|
||||||
|
ensurePageState(page);
|
||||||
|
const ref = requireRef(opts.ref);
|
||||||
|
try {
|
||||||
|
await refLocator(page, ref).highlight();
|
||||||
|
} catch (err) {
|
||||||
|
throw toAIFriendlyError(err, ref);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function traceStartViaPlaywright(opts: {
|
||||||
|
cdpUrl: string;
|
||||||
|
targetId?: string;
|
||||||
|
screenshots?: boolean;
|
||||||
|
snapshots?: boolean;
|
||||||
|
sources?: boolean;
|
||||||
|
}): Promise<void> {
|
||||||
|
const page = await getPageForTargetId(opts);
|
||||||
|
const context = page.context();
|
||||||
|
const ctxState = ensureContextState(context);
|
||||||
|
if (ctxState.traceActive) {
|
||||||
|
throw new Error(
|
||||||
|
"Trace already running. Stop the current trace before starting a new one.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await context.tracing.start({
|
||||||
|
screenshots: opts.screenshots ?? true,
|
||||||
|
snapshots: opts.snapshots ?? true,
|
||||||
|
sources: opts.sources ?? false,
|
||||||
|
});
|
||||||
|
ctxState.traceActive = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function traceStopViaPlaywright(opts: {
|
||||||
|
cdpUrl: string;
|
||||||
|
targetId?: string;
|
||||||
|
path: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
const page = await getPageForTargetId(opts);
|
||||||
|
const context = page.context();
|
||||||
|
const ctxState = ensureContextState(context);
|
||||||
|
if (!ctxState.traceActive) {
|
||||||
|
throw new Error("No active trace. Start a trace before stopping it.");
|
||||||
|
}
|
||||||
|
await context.tracing.stop({ path: opts.path });
|
||||||
|
ctxState.traceActive = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function clickViaPlaywright(opts: {
|
export async function clickViaPlaywright(opts: {
|
||||||
@@ -101,23 +242,28 @@ export async function clickViaPlaywright(opts: {
|
|||||||
targetId: opts.targetId,
|
targetId: opts.targetId,
|
||||||
});
|
});
|
||||||
ensurePageState(page);
|
ensurePageState(page);
|
||||||
const locator = refLocator(page, requireRef(opts.ref));
|
const ref = requireRef(opts.ref);
|
||||||
|
const locator = refLocator(page, ref);
|
||||||
const timeout = Math.max(
|
const timeout = Math.max(
|
||||||
500,
|
500,
|
||||||
Math.min(60_000, Math.floor(opts.timeoutMs ?? 8000)),
|
Math.min(60_000, Math.floor(opts.timeoutMs ?? 8000)),
|
||||||
);
|
);
|
||||||
if (opts.doubleClick) {
|
try {
|
||||||
await locator.dblclick({
|
if (opts.doubleClick) {
|
||||||
timeout,
|
await locator.dblclick({
|
||||||
button: opts.button,
|
timeout,
|
||||||
modifiers: opts.modifiers,
|
button: opts.button,
|
||||||
});
|
modifiers: opts.modifiers,
|
||||||
} else {
|
});
|
||||||
await locator.click({
|
} else {
|
||||||
timeout,
|
await locator.click({
|
||||||
button: opts.button,
|
timeout,
|
||||||
modifiers: opts.modifiers,
|
button: opts.button,
|
||||||
});
|
modifiers: opts.modifiers,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
throw toAIFriendlyError(err, ref);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,9 +276,13 @@ export async function hoverViaPlaywright(opts: {
|
|||||||
const ref = requireRef(opts.ref);
|
const ref = requireRef(opts.ref);
|
||||||
const page = await getPageForTargetId(opts);
|
const page = await getPageForTargetId(opts);
|
||||||
ensurePageState(page);
|
ensurePageState(page);
|
||||||
await refLocator(page, ref).hover({
|
try {
|
||||||
timeout: Math.max(500, Math.min(60_000, opts.timeoutMs ?? 8000)),
|
await refLocator(page, ref).hover({
|
||||||
});
|
timeout: Math.max(500, Math.min(60_000, opts.timeoutMs ?? 8000)),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
throw toAIFriendlyError(err, ref);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function dragViaPlaywright(opts: {
|
export async function dragViaPlaywright(opts: {
|
||||||
@@ -147,9 +297,13 @@ export async function dragViaPlaywright(opts: {
|
|||||||
if (!startRef || !endRef) throw new Error("startRef and endRef are required");
|
if (!startRef || !endRef) throw new Error("startRef and endRef are required");
|
||||||
const page = await getPageForTargetId(opts);
|
const page = await getPageForTargetId(opts);
|
||||||
ensurePageState(page);
|
ensurePageState(page);
|
||||||
await refLocator(page, startRef).dragTo(refLocator(page, endRef), {
|
try {
|
||||||
timeout: Math.max(500, Math.min(60_000, opts.timeoutMs ?? 8000)),
|
await refLocator(page, startRef).dragTo(refLocator(page, endRef), {
|
||||||
});
|
timeout: Math.max(500, Math.min(60_000, opts.timeoutMs ?? 8000)),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
throw toAIFriendlyError(err, `${startRef} -> ${endRef}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function selectOptionViaPlaywright(opts: {
|
export async function selectOptionViaPlaywright(opts: {
|
||||||
@@ -163,9 +317,13 @@ export async function selectOptionViaPlaywright(opts: {
|
|||||||
if (!opts.values?.length) throw new Error("values are required");
|
if (!opts.values?.length) throw new Error("values are required");
|
||||||
const page = await getPageForTargetId(opts);
|
const page = await getPageForTargetId(opts);
|
||||||
ensurePageState(page);
|
ensurePageState(page);
|
||||||
await refLocator(page, ref).selectOption(opts.values, {
|
try {
|
||||||
timeout: Math.max(500, Math.min(60_000, opts.timeoutMs ?? 8000)),
|
await refLocator(page, ref).selectOption(opts.values, {
|
||||||
});
|
timeout: Math.max(500, Math.min(60_000, opts.timeoutMs ?? 8000)),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
throw toAIFriendlyError(err, ref);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function pressKeyViaPlaywright(opts: {
|
export async function pressKeyViaPlaywright(opts: {
|
||||||
@@ -183,6 +341,330 @@ export async function pressKeyViaPlaywright(opts: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function cookiesGetViaPlaywright(opts: {
|
||||||
|
cdpUrl: string;
|
||||||
|
targetId?: string;
|
||||||
|
}): Promise<{ cookies: unknown[] }> {
|
||||||
|
const page = await getPageForTargetId(opts);
|
||||||
|
ensurePageState(page);
|
||||||
|
const cookies = await page.context().cookies();
|
||||||
|
return { cookies };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cookiesSetViaPlaywright(opts: {
|
||||||
|
cdpUrl: string;
|
||||||
|
targetId?: string;
|
||||||
|
cookie: {
|
||||||
|
name: string;
|
||||||
|
value: string;
|
||||||
|
url?: string;
|
||||||
|
domain?: string;
|
||||||
|
path?: string;
|
||||||
|
expires?: number;
|
||||||
|
httpOnly?: boolean;
|
||||||
|
secure?: boolean;
|
||||||
|
sameSite?: "Lax" | "None" | "Strict";
|
||||||
|
};
|
||||||
|
}): Promise<void> {
|
||||||
|
const page = await getPageForTargetId(opts);
|
||||||
|
ensurePageState(page);
|
||||||
|
const cookie = opts.cookie;
|
||||||
|
if (!cookie.name || cookie.value === undefined) {
|
||||||
|
throw new Error("cookie name and value are required");
|
||||||
|
}
|
||||||
|
const hasUrl = typeof cookie.url === "string" && cookie.url.trim();
|
||||||
|
const hasDomainPath =
|
||||||
|
typeof cookie.domain === "string" &&
|
||||||
|
cookie.domain.trim() &&
|
||||||
|
typeof cookie.path === "string" &&
|
||||||
|
cookie.path.trim();
|
||||||
|
if (!hasUrl && !hasDomainPath) {
|
||||||
|
throw new Error("cookie requires url, or domain+path");
|
||||||
|
}
|
||||||
|
await page.context().addCookies([cookie]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cookiesClearViaPlaywright(opts: {
|
||||||
|
cdpUrl: string;
|
||||||
|
targetId?: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
const page = await getPageForTargetId(opts);
|
||||||
|
ensurePageState(page);
|
||||||
|
await page.context().clearCookies();
|
||||||
|
}
|
||||||
|
|
||||||
|
type StorageKind = "local" | "session";
|
||||||
|
|
||||||
|
export async function storageGetViaPlaywright(opts: {
|
||||||
|
cdpUrl: string;
|
||||||
|
targetId?: string;
|
||||||
|
kind: StorageKind;
|
||||||
|
key?: string;
|
||||||
|
}): Promise<{ values: Record<string, string> }> {
|
||||||
|
const page = await getPageForTargetId(opts);
|
||||||
|
ensurePageState(page);
|
||||||
|
const kind = opts.kind;
|
||||||
|
const key = typeof opts.key === "string" ? opts.key : undefined;
|
||||||
|
const values = await page.evaluate(
|
||||||
|
({ kind: kind2, key: key2 }) => {
|
||||||
|
const store =
|
||||||
|
kind2 === "session" ? window.sessionStorage : window.localStorage;
|
||||||
|
if (key2) {
|
||||||
|
const value = store.getItem(key2);
|
||||||
|
return value === null ? {} : { [key2]: value };
|
||||||
|
}
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
for (let i = 0; i < store.length; i += 1) {
|
||||||
|
const k = store.key(i);
|
||||||
|
if (!k) continue;
|
||||||
|
const v = store.getItem(k);
|
||||||
|
if (v !== null) out[k] = v;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
},
|
||||||
|
{ kind, key },
|
||||||
|
);
|
||||||
|
return { values: values ?? {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function storageSetViaPlaywright(opts: {
|
||||||
|
cdpUrl: string;
|
||||||
|
targetId?: string;
|
||||||
|
kind: StorageKind;
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
const page = await getPageForTargetId(opts);
|
||||||
|
ensurePageState(page);
|
||||||
|
const key = String(opts.key ?? "");
|
||||||
|
if (!key) throw new Error("key is required");
|
||||||
|
await page.evaluate(
|
||||||
|
({ kind, key: k, value }) => {
|
||||||
|
const store =
|
||||||
|
kind === "session" ? window.sessionStorage : window.localStorage;
|
||||||
|
store.setItem(k, value);
|
||||||
|
},
|
||||||
|
{ kind: opts.kind, key, value: String(opts.value ?? "") },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function storageClearViaPlaywright(opts: {
|
||||||
|
cdpUrl: string;
|
||||||
|
targetId?: string;
|
||||||
|
kind: StorageKind;
|
||||||
|
}): Promise<void> {
|
||||||
|
const page = await getPageForTargetId(opts);
|
||||||
|
ensurePageState(page);
|
||||||
|
await page.evaluate(
|
||||||
|
({ kind }) => {
|
||||||
|
const store =
|
||||||
|
kind === "session" ? window.sessionStorage : window.localStorage;
|
||||||
|
store.clear();
|
||||||
|
},
|
||||||
|
{ kind: opts.kind },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setOfflineViaPlaywright(opts: {
|
||||||
|
cdpUrl: string;
|
||||||
|
targetId?: string;
|
||||||
|
offline: boolean;
|
||||||
|
}): Promise<void> {
|
||||||
|
const page = await getPageForTargetId(opts);
|
||||||
|
ensurePageState(page);
|
||||||
|
await page.context().setOffline(Boolean(opts.offline));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setExtraHTTPHeadersViaPlaywright(opts: {
|
||||||
|
cdpUrl: string;
|
||||||
|
targetId?: string;
|
||||||
|
headers: Record<string, string>;
|
||||||
|
}): Promise<void> {
|
||||||
|
const page = await getPageForTargetId(opts);
|
||||||
|
ensurePageState(page);
|
||||||
|
await page.context().setExtraHTTPHeaders(opts.headers);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setHttpCredentialsViaPlaywright(opts: {
|
||||||
|
cdpUrl: string;
|
||||||
|
targetId?: string;
|
||||||
|
username?: string;
|
||||||
|
password?: string;
|
||||||
|
clear?: boolean;
|
||||||
|
}): Promise<void> {
|
||||||
|
const page = await getPageForTargetId(opts);
|
||||||
|
ensurePageState(page);
|
||||||
|
if (opts.clear) {
|
||||||
|
await page.context().setHTTPCredentials(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const username = String(opts.username ?? "");
|
||||||
|
const password = String(opts.password ?? "");
|
||||||
|
if (!username) throw new Error("username is required (or set clear=true)");
|
||||||
|
await page.context().setHTTPCredentials({ username, password });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setGeolocationViaPlaywright(opts: {
|
||||||
|
cdpUrl: string;
|
||||||
|
targetId?: string;
|
||||||
|
latitude?: number;
|
||||||
|
longitude?: number;
|
||||||
|
accuracy?: number;
|
||||||
|
origin?: string;
|
||||||
|
clear?: boolean;
|
||||||
|
}): Promise<void> {
|
||||||
|
const page = await getPageForTargetId(opts);
|
||||||
|
ensurePageState(page);
|
||||||
|
const context = page.context();
|
||||||
|
if (opts.clear) {
|
||||||
|
await context.setGeolocation(null);
|
||||||
|
await context.clearPermissions().catch(() => {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof opts.latitude !== "number" || typeof opts.longitude !== "number") {
|
||||||
|
throw new Error("latitude and longitude are required (or set clear=true)");
|
||||||
|
}
|
||||||
|
await context.setGeolocation({
|
||||||
|
latitude: opts.latitude,
|
||||||
|
longitude: opts.longitude,
|
||||||
|
accuracy: typeof opts.accuracy === "number" ? opts.accuracy : undefined,
|
||||||
|
});
|
||||||
|
const origin =
|
||||||
|
opts.origin?.trim() ||
|
||||||
|
(() => {
|
||||||
|
try {
|
||||||
|
return new URL(page.url()).origin;
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
if (origin) {
|
||||||
|
await context.grantPermissions(["geolocation"], { origin }).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function emulateMediaViaPlaywright(opts: {
|
||||||
|
cdpUrl: string;
|
||||||
|
targetId?: string;
|
||||||
|
colorScheme: "dark" | "light" | "no-preference" | null;
|
||||||
|
}): Promise<void> {
|
||||||
|
const page = await getPageForTargetId(opts);
|
||||||
|
ensurePageState(page);
|
||||||
|
await page.emulateMedia({ colorScheme: opts.colorScheme });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withCdpSession<T>(
|
||||||
|
page: Page,
|
||||||
|
fn: (session: CDPSession) => Promise<T>,
|
||||||
|
): Promise<T> {
|
||||||
|
const session = await page.context().newCDPSession(page);
|
||||||
|
try {
|
||||||
|
return await fn(session);
|
||||||
|
} finally {
|
||||||
|
await session.detach().catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setLocaleViaPlaywright(opts: {
|
||||||
|
cdpUrl: string;
|
||||||
|
targetId?: string;
|
||||||
|
locale: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
const page = await getPageForTargetId(opts);
|
||||||
|
ensurePageState(page);
|
||||||
|
const locale = String(opts.locale ?? "").trim();
|
||||||
|
if (!locale) throw new Error("locale is required");
|
||||||
|
await withCdpSession(page, async (session) => {
|
||||||
|
try {
|
||||||
|
await session.send("Emulation.setLocaleOverride", { locale });
|
||||||
|
} catch (err) {
|
||||||
|
if (
|
||||||
|
String(err).includes("Another locale override is already in effect")
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setTimezoneViaPlaywright(opts: {
|
||||||
|
cdpUrl: string;
|
||||||
|
targetId?: string;
|
||||||
|
timezoneId: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
const page = await getPageForTargetId(opts);
|
||||||
|
ensurePageState(page);
|
||||||
|
const timezoneId = String(opts.timezoneId ?? "").trim();
|
||||||
|
if (!timezoneId) throw new Error("timezoneId is required");
|
||||||
|
await withCdpSession(page, async (session) => {
|
||||||
|
try {
|
||||||
|
await session.send("Emulation.setTimezoneOverride", { timezoneId });
|
||||||
|
} catch (err) {
|
||||||
|
const msg = String(err);
|
||||||
|
if (msg.includes("Timezone override is already in effect")) return;
|
||||||
|
if (msg.includes("Invalid timezone"))
|
||||||
|
throw new Error(`Invalid timezone ID: ${timezoneId}`);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setDeviceViaPlaywright(opts: {
|
||||||
|
cdpUrl: string;
|
||||||
|
targetId?: string;
|
||||||
|
name: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
const page = await getPageForTargetId(opts);
|
||||||
|
ensurePageState(page);
|
||||||
|
const name = String(opts.name ?? "").trim();
|
||||||
|
if (!name) throw new Error("device name is required");
|
||||||
|
const descriptor = (playwrightDevices as Record<string, unknown>)[name] as
|
||||||
|
| {
|
||||||
|
userAgent?: string;
|
||||||
|
viewport?: { width: number; height: number };
|
||||||
|
deviceScaleFactor?: number;
|
||||||
|
isMobile?: boolean;
|
||||||
|
hasTouch?: boolean;
|
||||||
|
locale?: string;
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
if (!descriptor) {
|
||||||
|
throw new Error(`Unknown device "${name}".`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (descriptor.viewport) {
|
||||||
|
await page.setViewportSize({
|
||||||
|
width: descriptor.viewport.width,
|
||||||
|
height: descriptor.viewport.height,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await withCdpSession(page, async (session) => {
|
||||||
|
if (descriptor.userAgent || descriptor.locale) {
|
||||||
|
await session.send("Emulation.setUserAgentOverride", {
|
||||||
|
userAgent: descriptor.userAgent ?? "",
|
||||||
|
acceptLanguage: descriptor.locale ?? undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (descriptor.viewport) {
|
||||||
|
await session.send("Emulation.setDeviceMetricsOverride", {
|
||||||
|
mobile: Boolean(descriptor.isMobile),
|
||||||
|
width: descriptor.viewport.width,
|
||||||
|
height: descriptor.viewport.height,
|
||||||
|
deviceScaleFactor: descriptor.deviceScaleFactor ?? 1,
|
||||||
|
screenWidth: descriptor.viewport.width,
|
||||||
|
screenHeight: descriptor.viewport.height,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (descriptor.hasTouch) {
|
||||||
|
await session.send("Emulation.setTouchEmulationEnabled", {
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function typeViaPlaywright(opts: {
|
export async function typeViaPlaywright(opts: {
|
||||||
cdpUrl: string;
|
cdpUrl: string;
|
||||||
targetId?: string;
|
targetId?: string;
|
||||||
@@ -195,16 +677,21 @@ export async function typeViaPlaywright(opts: {
|
|||||||
const text = String(opts.text ?? "");
|
const text = String(opts.text ?? "");
|
||||||
const page = await getPageForTargetId(opts);
|
const page = await getPageForTargetId(opts);
|
||||||
ensurePageState(page);
|
ensurePageState(page);
|
||||||
const locator = refLocator(page, requireRef(opts.ref));
|
const ref = requireRef(opts.ref);
|
||||||
|
const locator = refLocator(page, ref);
|
||||||
const timeout = Math.max(500, Math.min(60_000, opts.timeoutMs ?? 8000));
|
const timeout = Math.max(500, Math.min(60_000, opts.timeoutMs ?? 8000));
|
||||||
if (opts.slowly) {
|
try {
|
||||||
await locator.click({ timeout });
|
if (opts.slowly) {
|
||||||
await locator.type(text, { timeout, delay: 75 });
|
await locator.click({ timeout });
|
||||||
} else {
|
await locator.type(text, { timeout, delay: 75 });
|
||||||
await locator.fill(text, { timeout });
|
} else {
|
||||||
}
|
await locator.fill(text, { timeout });
|
||||||
if (opts.submit) {
|
}
|
||||||
await locator.press("Enter", { timeout });
|
if (opts.submit) {
|
||||||
|
await locator.press("Enter", { timeout });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
throw toAIFriendlyError(err, ref);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,9 +699,11 @@ export async function fillFormViaPlaywright(opts: {
|
|||||||
cdpUrl: string;
|
cdpUrl: string;
|
||||||
targetId?: string;
|
targetId?: string;
|
||||||
fields: BrowserFormField[];
|
fields: BrowserFormField[];
|
||||||
|
timeoutMs?: number;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const page = await getPageForTargetId(opts);
|
const page = await getPageForTargetId(opts);
|
||||||
ensurePageState(page);
|
ensurePageState(page);
|
||||||
|
const timeout = Math.max(500, Math.min(60_000, opts.timeoutMs ?? 8000));
|
||||||
for (const field of opts.fields) {
|
for (const field of opts.fields) {
|
||||||
const ref = field.ref.trim();
|
const ref = field.ref.trim();
|
||||||
const type = field.type.trim();
|
const type = field.type.trim();
|
||||||
@@ -233,10 +722,18 @@ export async function fillFormViaPlaywright(opts: {
|
|||||||
rawValue === 1 ||
|
rawValue === 1 ||
|
||||||
rawValue === "1" ||
|
rawValue === "1" ||
|
||||||
rawValue === "true";
|
rawValue === "true";
|
||||||
await locator.setChecked(checked);
|
try {
|
||||||
|
await locator.setChecked(checked, { timeout });
|
||||||
|
} catch (err) {
|
||||||
|
throw toAIFriendlyError(err, ref);
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
await locator.fill(value);
|
try {
|
||||||
|
await locator.fill(value, { timeout });
|
||||||
|
} catch (err) {
|
||||||
|
throw toAIFriendlyError(err, ref);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -359,7 +856,11 @@ export async function setInputFilesViaPlaywright(opts: {
|
|||||||
? refLocator(page, inputRef)
|
? refLocator(page, inputRef)
|
||||||
: page.locator(element).first();
|
: page.locator(element).first();
|
||||||
|
|
||||||
await locator.setInputFiles(opts.paths);
|
try {
|
||||||
|
await locator.setInputFiles(opts.paths);
|
||||||
|
} catch (err) {
|
||||||
|
throw toAIFriendlyError(err, inputRef || element);
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const handle = await locator.elementHandle();
|
const handle = await locator.elementHandle();
|
||||||
if (handle) {
|
if (handle) {
|
||||||
@@ -421,30 +922,54 @@ export async function waitForViaPlaywright(opts: {
|
|||||||
timeMs?: number;
|
timeMs?: number;
|
||||||
text?: string;
|
text?: string;
|
||||||
textGone?: string;
|
textGone?: string;
|
||||||
|
selector?: string;
|
||||||
|
url?: string;
|
||||||
|
loadState?: "load" | "domcontentloaded" | "networkidle";
|
||||||
|
fn?: string;
|
||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const page = await getPageForTargetId(opts);
|
const page = await getPageForTargetId(opts);
|
||||||
ensurePageState(page);
|
ensurePageState(page);
|
||||||
|
const timeout = Math.max(500, Math.min(120_000, opts.timeoutMs ?? 20_000));
|
||||||
|
|
||||||
if (typeof opts.timeMs === "number" && Number.isFinite(opts.timeMs)) {
|
if (typeof opts.timeMs === "number" && Number.isFinite(opts.timeMs)) {
|
||||||
await page.waitForTimeout(Math.max(0, opts.timeMs));
|
await page.waitForTimeout(Math.max(0, opts.timeMs));
|
||||||
}
|
}
|
||||||
if (opts.text) {
|
if (opts.text) {
|
||||||
await page
|
await page.getByText(opts.text).first().waitFor({
|
||||||
.getByText(opts.text)
|
state: "visible",
|
||||||
.first()
|
timeout,
|
||||||
.waitFor({
|
});
|
||||||
state: "visible",
|
|
||||||
timeout: Math.max(500, Math.min(120_000, opts.timeoutMs ?? 20_000)),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
if (opts.textGone) {
|
if (opts.textGone) {
|
||||||
await page
|
await page.getByText(opts.textGone).first().waitFor({
|
||||||
.getByText(opts.textGone)
|
state: "hidden",
|
||||||
.first()
|
timeout,
|
||||||
.waitFor({
|
});
|
||||||
state: "hidden",
|
}
|
||||||
timeout: Math.max(500, Math.min(120_000, opts.timeoutMs ?? 20_000)),
|
if (opts.selector) {
|
||||||
});
|
const selector = String(opts.selector).trim();
|
||||||
|
if (selector) {
|
||||||
|
await page
|
||||||
|
.locator(selector)
|
||||||
|
.first()
|
||||||
|
.waitFor({ state: "visible", timeout });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (opts.url) {
|
||||||
|
const url = String(opts.url).trim();
|
||||||
|
if (url) {
|
||||||
|
await page.waitForURL(url, { timeout });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (opts.loadState) {
|
||||||
|
await page.waitForLoadState(opts.loadState, { timeout });
|
||||||
|
}
|
||||||
|
if (opts.fn) {
|
||||||
|
const fn = String(opts.fn).trim();
|
||||||
|
if (fn) {
|
||||||
|
await page.waitForFunction(fn, { timeout });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import crypto from "node:crypto";
|
||||||
|
import fs from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
import type express from "express";
|
import type express from "express";
|
||||||
@@ -141,7 +143,7 @@ export function registerBrowserAgentRoutes(
|
|||||||
const body = readBody(req);
|
const body = readBody(req);
|
||||||
const kind = toStringOrEmpty(body.kind) as ActKind;
|
const kind = toStringOrEmpty(body.kind) as ActKind;
|
||||||
const targetId = toStringOrEmpty(body.targetId) || undefined;
|
const targetId = toStringOrEmpty(body.targetId) || undefined;
|
||||||
if (Object.hasOwn(body, "selector")) {
|
if (Object.hasOwn(body, "selector") && kind !== "wait") {
|
||||||
return jsonError(res, 400, SELECTOR_UNSUPPORTED_MESSAGE);
|
return jsonError(res, 400, SELECTOR_UNSUPPORTED_MESSAGE);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,6 +174,7 @@ export function registerBrowserAgentRoutes(
|
|||||||
const ref = toStringOrEmpty(body.ref);
|
const ref = toStringOrEmpty(body.ref);
|
||||||
if (!ref) return jsonError(res, 400, "ref is required");
|
if (!ref) return jsonError(res, 400, "ref is required");
|
||||||
const doubleClick = toBoolean(body.doubleClick) ?? false;
|
const doubleClick = toBoolean(body.doubleClick) ?? false;
|
||||||
|
const timeoutMs = toNumber(body.timeoutMs);
|
||||||
const buttonRaw = toStringOrEmpty(body.button) || "";
|
const buttonRaw = toStringOrEmpty(body.button) || "";
|
||||||
const button = buttonRaw ? parseClickButton(buttonRaw) : undefined;
|
const button = buttonRaw ? parseClickButton(buttonRaw) : undefined;
|
||||||
if (buttonRaw && !button)
|
if (buttonRaw && !button)
|
||||||
@@ -205,6 +208,7 @@ export function registerBrowserAgentRoutes(
|
|||||||
};
|
};
|
||||||
if (button) clickRequest.button = button;
|
if (button) clickRequest.button = button;
|
||||||
if (modifiers) clickRequest.modifiers = modifiers;
|
if (modifiers) clickRequest.modifiers = modifiers;
|
||||||
|
if (timeoutMs) clickRequest.timeoutMs = timeoutMs;
|
||||||
await pw.clickViaPlaywright(clickRequest);
|
await pw.clickViaPlaywright(clickRequest);
|
||||||
return res.json({ ok: true, targetId: tab.targetId, url: tab.url });
|
return res.json({ ok: true, targetId: tab.targetId, url: tab.url });
|
||||||
}
|
}
|
||||||
@@ -216,6 +220,7 @@ export function registerBrowserAgentRoutes(
|
|||||||
const text = body.text;
|
const text = body.text;
|
||||||
const submit = toBoolean(body.submit) ?? false;
|
const submit = toBoolean(body.submit) ?? false;
|
||||||
const slowly = toBoolean(body.slowly) ?? false;
|
const slowly = toBoolean(body.slowly) ?? false;
|
||||||
|
const timeoutMs = toNumber(body.timeoutMs);
|
||||||
const typeRequest: Parameters<typeof pw.typeViaPlaywright>[0] = {
|
const typeRequest: Parameters<typeof pw.typeViaPlaywright>[0] = {
|
||||||
cdpUrl,
|
cdpUrl,
|
||||||
targetId: tab.targetId,
|
targetId: tab.targetId,
|
||||||
@@ -224,23 +229,32 @@ export function registerBrowserAgentRoutes(
|
|||||||
submit,
|
submit,
|
||||||
slowly,
|
slowly,
|
||||||
};
|
};
|
||||||
|
if (timeoutMs) typeRequest.timeoutMs = timeoutMs;
|
||||||
await pw.typeViaPlaywright(typeRequest);
|
await pw.typeViaPlaywright(typeRequest);
|
||||||
return res.json({ ok: true, targetId: tab.targetId });
|
return res.json({ ok: true, targetId: tab.targetId });
|
||||||
}
|
}
|
||||||
case "press": {
|
case "press": {
|
||||||
const key = toStringOrEmpty(body.key);
|
const key = toStringOrEmpty(body.key);
|
||||||
if (!key) return jsonError(res, 400, "key is required");
|
if (!key) return jsonError(res, 400, "key is required");
|
||||||
|
const delayMs = toNumber(body.delayMs);
|
||||||
await pw.pressKeyViaPlaywright({
|
await pw.pressKeyViaPlaywright({
|
||||||
cdpUrl,
|
cdpUrl,
|
||||||
targetId: tab.targetId,
|
targetId: tab.targetId,
|
||||||
key,
|
key,
|
||||||
|
delayMs: delayMs ?? undefined,
|
||||||
});
|
});
|
||||||
return res.json({ ok: true, targetId: tab.targetId });
|
return res.json({ ok: true, targetId: tab.targetId });
|
||||||
}
|
}
|
||||||
case "hover": {
|
case "hover": {
|
||||||
const ref = toStringOrEmpty(body.ref);
|
const ref = toStringOrEmpty(body.ref);
|
||||||
if (!ref) return jsonError(res, 400, "ref is required");
|
if (!ref) return jsonError(res, 400, "ref is required");
|
||||||
await pw.hoverViaPlaywright({ cdpUrl, targetId: tab.targetId, ref });
|
const timeoutMs = toNumber(body.timeoutMs);
|
||||||
|
await pw.hoverViaPlaywright({
|
||||||
|
cdpUrl,
|
||||||
|
targetId: tab.targetId,
|
||||||
|
ref,
|
||||||
|
timeoutMs: timeoutMs ?? undefined,
|
||||||
|
});
|
||||||
return res.json({ ok: true, targetId: tab.targetId });
|
return res.json({ ok: true, targetId: tab.targetId });
|
||||||
}
|
}
|
||||||
case "drag": {
|
case "drag": {
|
||||||
@@ -248,11 +262,13 @@ export function registerBrowserAgentRoutes(
|
|||||||
const endRef = toStringOrEmpty(body.endRef);
|
const endRef = toStringOrEmpty(body.endRef);
|
||||||
if (!startRef || !endRef)
|
if (!startRef || !endRef)
|
||||||
return jsonError(res, 400, "startRef and endRef are required");
|
return jsonError(res, 400, "startRef and endRef are required");
|
||||||
|
const timeoutMs = toNumber(body.timeoutMs);
|
||||||
await pw.dragViaPlaywright({
|
await pw.dragViaPlaywright({
|
||||||
cdpUrl,
|
cdpUrl,
|
||||||
targetId: tab.targetId,
|
targetId: tab.targetId,
|
||||||
startRef,
|
startRef,
|
||||||
endRef,
|
endRef,
|
||||||
|
timeoutMs: timeoutMs ?? undefined,
|
||||||
});
|
});
|
||||||
return res.json({ ok: true, targetId: tab.targetId });
|
return res.json({ ok: true, targetId: tab.targetId });
|
||||||
}
|
}
|
||||||
@@ -261,11 +277,13 @@ export function registerBrowserAgentRoutes(
|
|||||||
const values = toStringArray(body.values);
|
const values = toStringArray(body.values);
|
||||||
if (!ref || !values?.length)
|
if (!ref || !values?.length)
|
||||||
return jsonError(res, 400, "ref and values are required");
|
return jsonError(res, 400, "ref and values are required");
|
||||||
|
const timeoutMs = toNumber(body.timeoutMs);
|
||||||
await pw.selectOptionViaPlaywright({
|
await pw.selectOptionViaPlaywright({
|
||||||
cdpUrl,
|
cdpUrl,
|
||||||
targetId: tab.targetId,
|
targetId: tab.targetId,
|
||||||
ref,
|
ref,
|
||||||
values,
|
values,
|
||||||
|
timeoutMs: timeoutMs ?? undefined,
|
||||||
});
|
});
|
||||||
return res.json({ ok: true, targetId: tab.targetId });
|
return res.json({ ok: true, targetId: tab.targetId });
|
||||||
}
|
}
|
||||||
@@ -290,10 +308,12 @@ export function registerBrowserAgentRoutes(
|
|||||||
})
|
})
|
||||||
.filter((field): field is BrowserFormField => field !== null);
|
.filter((field): field is BrowserFormField => field !== null);
|
||||||
if (!fields.length) return jsonError(res, 400, "fields are required");
|
if (!fields.length) return jsonError(res, 400, "fields are required");
|
||||||
|
const timeoutMs = toNumber(body.timeoutMs);
|
||||||
await pw.fillFormViaPlaywright({
|
await pw.fillFormViaPlaywright({
|
||||||
cdpUrl,
|
cdpUrl,
|
||||||
targetId: tab.targetId,
|
targetId: tab.targetId,
|
||||||
fields,
|
fields,
|
||||||
|
timeoutMs: timeoutMs ?? undefined,
|
||||||
});
|
});
|
||||||
return res.json({ ok: true, targetId: tab.targetId });
|
return res.json({ ok: true, targetId: tab.targetId });
|
||||||
}
|
}
|
||||||
@@ -314,12 +334,43 @@ export function registerBrowserAgentRoutes(
|
|||||||
const timeMs = toNumber(body.timeMs);
|
const timeMs = toNumber(body.timeMs);
|
||||||
const text = toStringOrEmpty(body.text) || undefined;
|
const text = toStringOrEmpty(body.text) || undefined;
|
||||||
const textGone = toStringOrEmpty(body.textGone) || undefined;
|
const textGone = toStringOrEmpty(body.textGone) || undefined;
|
||||||
|
const selector = toStringOrEmpty(body.selector) || undefined;
|
||||||
|
const url = toStringOrEmpty(body.url) || undefined;
|
||||||
|
const loadStateRaw = toStringOrEmpty(body.loadState);
|
||||||
|
const loadState =
|
||||||
|
loadStateRaw === "load" ||
|
||||||
|
loadStateRaw === "domcontentloaded" ||
|
||||||
|
loadStateRaw === "networkidle"
|
||||||
|
? (loadStateRaw as "load" | "domcontentloaded" | "networkidle")
|
||||||
|
: undefined;
|
||||||
|
const fn = toStringOrEmpty(body.fn) || undefined;
|
||||||
|
const timeoutMs = toNumber(body.timeoutMs) ?? undefined;
|
||||||
|
if (
|
||||||
|
timeMs === undefined &&
|
||||||
|
!text &&
|
||||||
|
!textGone &&
|
||||||
|
!selector &&
|
||||||
|
!url &&
|
||||||
|
!loadState &&
|
||||||
|
!fn
|
||||||
|
) {
|
||||||
|
return jsonError(
|
||||||
|
res,
|
||||||
|
400,
|
||||||
|
"wait requires at least one of: timeMs, text, textGone, selector, url, loadState, fn",
|
||||||
|
);
|
||||||
|
}
|
||||||
await pw.waitForViaPlaywright({
|
await pw.waitForViaPlaywright({
|
||||||
cdpUrl,
|
cdpUrl,
|
||||||
targetId: tab.targetId,
|
targetId: tab.targetId,
|
||||||
timeMs,
|
timeMs,
|
||||||
text,
|
text,
|
||||||
textGone,
|
textGone,
|
||||||
|
selector,
|
||||||
|
url,
|
||||||
|
loadState,
|
||||||
|
fn,
|
||||||
|
timeoutMs,
|
||||||
});
|
});
|
||||||
return res.json({ ok: true, targetId: tab.targetId });
|
return res.json({ ok: true, targetId: tab.targetId });
|
||||||
}
|
}
|
||||||
@@ -452,6 +503,494 @@ export function registerBrowserAgentRoutes(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get("/errors", async (req, res) => {
|
||||||
|
const profileCtx = resolveProfileContext(req, res, ctx);
|
||||||
|
if (!profileCtx) return;
|
||||||
|
const targetId =
|
||||||
|
typeof req.query.targetId === "string" ? req.query.targetId.trim() : "";
|
||||||
|
const clear = toBoolean(req.query.clear) ?? false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tab = await profileCtx.ensureTabAvailable(targetId || undefined);
|
||||||
|
const pw = await requirePwAi(res, "page errors");
|
||||||
|
if (!pw) return;
|
||||||
|
const result = await pw.getPageErrorsViaPlaywright({
|
||||||
|
cdpUrl: profileCtx.profile.cdpUrl,
|
||||||
|
targetId: tab.targetId,
|
||||||
|
clear,
|
||||||
|
});
|
||||||
|
res.json({ ok: true, targetId: tab.targetId, ...result });
|
||||||
|
} catch (err) {
|
||||||
|
handleRouteError(ctx, res, err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/requests", async (req, res) => {
|
||||||
|
const profileCtx = resolveProfileContext(req, res, ctx);
|
||||||
|
if (!profileCtx) return;
|
||||||
|
const targetId =
|
||||||
|
typeof req.query.targetId === "string" ? req.query.targetId.trim() : "";
|
||||||
|
const filter = typeof req.query.filter === "string" ? req.query.filter : "";
|
||||||
|
const clear = toBoolean(req.query.clear) ?? false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tab = await profileCtx.ensureTabAvailable(targetId || undefined);
|
||||||
|
const pw = await requirePwAi(res, "network requests");
|
||||||
|
if (!pw) return;
|
||||||
|
const result = await pw.getNetworkRequestsViaPlaywright({
|
||||||
|
cdpUrl: profileCtx.profile.cdpUrl,
|
||||||
|
targetId: tab.targetId,
|
||||||
|
filter: filter.trim() || undefined,
|
||||||
|
clear,
|
||||||
|
});
|
||||||
|
res.json({ ok: true, targetId: tab.targetId, ...result });
|
||||||
|
} catch (err) {
|
||||||
|
handleRouteError(ctx, res, err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/trace/start", async (req, res) => {
|
||||||
|
const profileCtx = resolveProfileContext(req, res, ctx);
|
||||||
|
if (!profileCtx) return;
|
||||||
|
const body = readBody(req);
|
||||||
|
const targetId = toStringOrEmpty(body.targetId) || undefined;
|
||||||
|
const screenshots = toBoolean(body.screenshots) ?? undefined;
|
||||||
|
const snapshots = toBoolean(body.snapshots) ?? undefined;
|
||||||
|
const sources = toBoolean(body.sources) ?? undefined;
|
||||||
|
try {
|
||||||
|
const tab = await profileCtx.ensureTabAvailable(targetId);
|
||||||
|
const pw = await requirePwAi(res, "trace start");
|
||||||
|
if (!pw) return;
|
||||||
|
await pw.traceStartViaPlaywright({
|
||||||
|
cdpUrl: profileCtx.profile.cdpUrl,
|
||||||
|
targetId: tab.targetId,
|
||||||
|
screenshots,
|
||||||
|
snapshots,
|
||||||
|
sources,
|
||||||
|
});
|
||||||
|
res.json({ ok: true, targetId: tab.targetId });
|
||||||
|
} catch (err) {
|
||||||
|
handleRouteError(ctx, res, err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/trace/stop", async (req, res) => {
|
||||||
|
const profileCtx = resolveProfileContext(req, res, ctx);
|
||||||
|
if (!profileCtx) return;
|
||||||
|
const body = readBody(req);
|
||||||
|
const targetId = toStringOrEmpty(body.targetId) || undefined;
|
||||||
|
const out = toStringOrEmpty(body.path) || "";
|
||||||
|
try {
|
||||||
|
const tab = await profileCtx.ensureTabAvailable(targetId);
|
||||||
|
const pw = await requirePwAi(res, "trace stop");
|
||||||
|
if (!pw) return;
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
const dir = "/tmp/clawdbot";
|
||||||
|
await fs.mkdir(dir, { recursive: true });
|
||||||
|
const tracePath = out.trim() || path.join(dir, `browser-trace-${id}.zip`);
|
||||||
|
await pw.traceStopViaPlaywright({
|
||||||
|
cdpUrl: profileCtx.profile.cdpUrl,
|
||||||
|
targetId: tab.targetId,
|
||||||
|
path: tracePath,
|
||||||
|
});
|
||||||
|
res.json({
|
||||||
|
ok: true,
|
||||||
|
targetId: tab.targetId,
|
||||||
|
path: path.resolve(tracePath),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
handleRouteError(ctx, res, err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/highlight", async (req, res) => {
|
||||||
|
const profileCtx = resolveProfileContext(req, res, ctx);
|
||||||
|
if (!profileCtx) return;
|
||||||
|
const body = readBody(req);
|
||||||
|
const targetId = toStringOrEmpty(body.targetId) || undefined;
|
||||||
|
const ref = toStringOrEmpty(body.ref);
|
||||||
|
if (!ref) return jsonError(res, 400, "ref is required");
|
||||||
|
try {
|
||||||
|
const tab = await profileCtx.ensureTabAvailable(targetId);
|
||||||
|
const pw = await requirePwAi(res, "highlight");
|
||||||
|
if (!pw) return;
|
||||||
|
await pw.highlightViaPlaywright({
|
||||||
|
cdpUrl: profileCtx.profile.cdpUrl,
|
||||||
|
targetId: tab.targetId,
|
||||||
|
ref,
|
||||||
|
});
|
||||||
|
res.json({ ok: true, targetId: tab.targetId });
|
||||||
|
} catch (err) {
|
||||||
|
handleRouteError(ctx, res, err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/cookies", async (req, res) => {
|
||||||
|
const profileCtx = resolveProfileContext(req, res, ctx);
|
||||||
|
if (!profileCtx) return;
|
||||||
|
const targetId =
|
||||||
|
typeof req.query.targetId === "string" ? req.query.targetId.trim() : "";
|
||||||
|
try {
|
||||||
|
const tab = await profileCtx.ensureTabAvailable(targetId || undefined);
|
||||||
|
const pw = await requirePwAi(res, "cookies");
|
||||||
|
if (!pw) return;
|
||||||
|
const result = await pw.cookiesGetViaPlaywright({
|
||||||
|
cdpUrl: profileCtx.profile.cdpUrl,
|
||||||
|
targetId: tab.targetId,
|
||||||
|
});
|
||||||
|
res.json({ ok: true, targetId: tab.targetId, ...result });
|
||||||
|
} catch (err) {
|
||||||
|
handleRouteError(ctx, res, err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/cookies/set", async (req, res) => {
|
||||||
|
const profileCtx = resolveProfileContext(req, res, ctx);
|
||||||
|
if (!profileCtx) return;
|
||||||
|
const body = readBody(req);
|
||||||
|
const targetId = toStringOrEmpty(body.targetId) || undefined;
|
||||||
|
const cookie =
|
||||||
|
body.cookie &&
|
||||||
|
typeof body.cookie === "object" &&
|
||||||
|
!Array.isArray(body.cookie)
|
||||||
|
? (body.cookie as Record<string, unknown>)
|
||||||
|
: null;
|
||||||
|
if (!cookie) return jsonError(res, 400, "cookie is required");
|
||||||
|
try {
|
||||||
|
const tab = await profileCtx.ensureTabAvailable(targetId);
|
||||||
|
const pw = await requirePwAi(res, "cookies set");
|
||||||
|
if (!pw) return;
|
||||||
|
await pw.cookiesSetViaPlaywright({
|
||||||
|
cdpUrl: profileCtx.profile.cdpUrl,
|
||||||
|
targetId: tab.targetId,
|
||||||
|
cookie: {
|
||||||
|
name: toStringOrEmpty(cookie.name),
|
||||||
|
value: toStringOrEmpty(cookie.value),
|
||||||
|
url: toStringOrEmpty(cookie.url) || undefined,
|
||||||
|
domain: toStringOrEmpty(cookie.domain) || undefined,
|
||||||
|
path: toStringOrEmpty(cookie.path) || undefined,
|
||||||
|
expires: toNumber(cookie.expires) ?? undefined,
|
||||||
|
httpOnly: toBoolean(cookie.httpOnly) ?? undefined,
|
||||||
|
secure: toBoolean(cookie.secure) ?? undefined,
|
||||||
|
sameSite:
|
||||||
|
cookie.sameSite === "Lax" ||
|
||||||
|
cookie.sameSite === "None" ||
|
||||||
|
cookie.sameSite === "Strict"
|
||||||
|
? (cookie.sameSite as "Lax" | "None" | "Strict")
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
res.json({ ok: true, targetId: tab.targetId });
|
||||||
|
} catch (err) {
|
||||||
|
handleRouteError(ctx, res, err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/cookies/clear", async (req, res) => {
|
||||||
|
const profileCtx = resolveProfileContext(req, res, ctx);
|
||||||
|
if (!profileCtx) return;
|
||||||
|
const body = readBody(req);
|
||||||
|
const targetId = toStringOrEmpty(body.targetId) || undefined;
|
||||||
|
try {
|
||||||
|
const tab = await profileCtx.ensureTabAvailable(targetId);
|
||||||
|
const pw = await requirePwAi(res, "cookies clear");
|
||||||
|
if (!pw) return;
|
||||||
|
await pw.cookiesClearViaPlaywright({
|
||||||
|
cdpUrl: profileCtx.profile.cdpUrl,
|
||||||
|
targetId: tab.targetId,
|
||||||
|
});
|
||||||
|
res.json({ ok: true, targetId: tab.targetId });
|
||||||
|
} catch (err) {
|
||||||
|
handleRouteError(ctx, res, err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/storage/:kind", async (req, res) => {
|
||||||
|
const profileCtx = resolveProfileContext(req, res, ctx);
|
||||||
|
if (!profileCtx) return;
|
||||||
|
const kind = toStringOrEmpty(req.params.kind);
|
||||||
|
if (kind !== "local" && kind !== "session")
|
||||||
|
return jsonError(res, 400, "kind must be local|session");
|
||||||
|
const targetId =
|
||||||
|
typeof req.query.targetId === "string" ? req.query.targetId.trim() : "";
|
||||||
|
const key = typeof req.query.key === "string" ? req.query.key : "";
|
||||||
|
try {
|
||||||
|
const tab = await profileCtx.ensureTabAvailable(targetId || undefined);
|
||||||
|
const pw = await requirePwAi(res, "storage get");
|
||||||
|
if (!pw) return;
|
||||||
|
const result = await pw.storageGetViaPlaywright({
|
||||||
|
cdpUrl: profileCtx.profile.cdpUrl,
|
||||||
|
targetId: tab.targetId,
|
||||||
|
kind,
|
||||||
|
key: key.trim() || undefined,
|
||||||
|
});
|
||||||
|
res.json({ ok: true, targetId: tab.targetId, ...result });
|
||||||
|
} catch (err) {
|
||||||
|
handleRouteError(ctx, res, err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/storage/:kind/set", async (req, res) => {
|
||||||
|
const profileCtx = resolveProfileContext(req, res, ctx);
|
||||||
|
if (!profileCtx) return;
|
||||||
|
const kind = toStringOrEmpty(req.params.kind);
|
||||||
|
if (kind !== "local" && kind !== "session")
|
||||||
|
return jsonError(res, 400, "kind must be local|session");
|
||||||
|
const body = readBody(req);
|
||||||
|
const targetId = toStringOrEmpty(body.targetId) || undefined;
|
||||||
|
const key = toStringOrEmpty(body.key);
|
||||||
|
if (!key) return jsonError(res, 400, "key is required");
|
||||||
|
const value = typeof body.value === "string" ? body.value : "";
|
||||||
|
try {
|
||||||
|
const tab = await profileCtx.ensureTabAvailable(targetId);
|
||||||
|
const pw = await requirePwAi(res, "storage set");
|
||||||
|
if (!pw) return;
|
||||||
|
await pw.storageSetViaPlaywright({
|
||||||
|
cdpUrl: profileCtx.profile.cdpUrl,
|
||||||
|
targetId: tab.targetId,
|
||||||
|
kind,
|
||||||
|
key,
|
||||||
|
value,
|
||||||
|
});
|
||||||
|
res.json({ ok: true, targetId: tab.targetId });
|
||||||
|
} catch (err) {
|
||||||
|
handleRouteError(ctx, res, err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/storage/:kind/clear", async (req, res) => {
|
||||||
|
const profileCtx = resolveProfileContext(req, res, ctx);
|
||||||
|
if (!profileCtx) return;
|
||||||
|
const kind = toStringOrEmpty(req.params.kind);
|
||||||
|
if (kind !== "local" && kind !== "session")
|
||||||
|
return jsonError(res, 400, "kind must be local|session");
|
||||||
|
const body = readBody(req);
|
||||||
|
const targetId = toStringOrEmpty(body.targetId) || undefined;
|
||||||
|
try {
|
||||||
|
const tab = await profileCtx.ensureTabAvailable(targetId);
|
||||||
|
const pw = await requirePwAi(res, "storage clear");
|
||||||
|
if (!pw) return;
|
||||||
|
await pw.storageClearViaPlaywright({
|
||||||
|
cdpUrl: profileCtx.profile.cdpUrl,
|
||||||
|
targetId: tab.targetId,
|
||||||
|
kind,
|
||||||
|
});
|
||||||
|
res.json({ ok: true, targetId: tab.targetId });
|
||||||
|
} catch (err) {
|
||||||
|
handleRouteError(ctx, res, err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/set/offline", async (req, res) => {
|
||||||
|
const profileCtx = resolveProfileContext(req, res, ctx);
|
||||||
|
if (!profileCtx) return;
|
||||||
|
const body = readBody(req);
|
||||||
|
const targetId = toStringOrEmpty(body.targetId) || undefined;
|
||||||
|
const offline = toBoolean(body.offline);
|
||||||
|
if (offline === undefined)
|
||||||
|
return jsonError(res, 400, "offline is required");
|
||||||
|
try {
|
||||||
|
const tab = await profileCtx.ensureTabAvailable(targetId);
|
||||||
|
const pw = await requirePwAi(res, "offline");
|
||||||
|
if (!pw) return;
|
||||||
|
await pw.setOfflineViaPlaywright({
|
||||||
|
cdpUrl: profileCtx.profile.cdpUrl,
|
||||||
|
targetId: tab.targetId,
|
||||||
|
offline,
|
||||||
|
});
|
||||||
|
res.json({ ok: true, targetId: tab.targetId });
|
||||||
|
} catch (err) {
|
||||||
|
handleRouteError(ctx, res, err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/set/headers", async (req, res) => {
|
||||||
|
const profileCtx = resolveProfileContext(req, res, ctx);
|
||||||
|
if (!profileCtx) return;
|
||||||
|
const body = readBody(req);
|
||||||
|
const targetId = toStringOrEmpty(body.targetId) || undefined;
|
||||||
|
const headers =
|
||||||
|
body.headers &&
|
||||||
|
typeof body.headers === "object" &&
|
||||||
|
!Array.isArray(body.headers)
|
||||||
|
? (body.headers as Record<string, unknown>)
|
||||||
|
: null;
|
||||||
|
if (!headers) return jsonError(res, 400, "headers is required");
|
||||||
|
const parsed: Record<string, string> = {};
|
||||||
|
for (const [k, v] of Object.entries(headers)) {
|
||||||
|
if (typeof v === "string") parsed[k] = v;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const tab = await profileCtx.ensureTabAvailable(targetId);
|
||||||
|
const pw = await requirePwAi(res, "headers");
|
||||||
|
if (!pw) return;
|
||||||
|
await pw.setExtraHTTPHeadersViaPlaywright({
|
||||||
|
cdpUrl: profileCtx.profile.cdpUrl,
|
||||||
|
targetId: tab.targetId,
|
||||||
|
headers: parsed,
|
||||||
|
});
|
||||||
|
res.json({ ok: true, targetId: tab.targetId });
|
||||||
|
} catch (err) {
|
||||||
|
handleRouteError(ctx, res, err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/set/credentials", async (req, res) => {
|
||||||
|
const profileCtx = resolveProfileContext(req, res, ctx);
|
||||||
|
if (!profileCtx) return;
|
||||||
|
const body = readBody(req);
|
||||||
|
const targetId = toStringOrEmpty(body.targetId) || undefined;
|
||||||
|
const clear = toBoolean(body.clear) ?? false;
|
||||||
|
const username = toStringOrEmpty(body.username) || undefined;
|
||||||
|
const password =
|
||||||
|
typeof body.password === "string" ? body.password : undefined;
|
||||||
|
try {
|
||||||
|
const tab = await profileCtx.ensureTabAvailable(targetId);
|
||||||
|
const pw = await requirePwAi(res, "http credentials");
|
||||||
|
if (!pw) return;
|
||||||
|
await pw.setHttpCredentialsViaPlaywright({
|
||||||
|
cdpUrl: profileCtx.profile.cdpUrl,
|
||||||
|
targetId: tab.targetId,
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
clear,
|
||||||
|
});
|
||||||
|
res.json({ ok: true, targetId: tab.targetId });
|
||||||
|
} catch (err) {
|
||||||
|
handleRouteError(ctx, res, err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/set/geolocation", async (req, res) => {
|
||||||
|
const profileCtx = resolveProfileContext(req, res, ctx);
|
||||||
|
if (!profileCtx) return;
|
||||||
|
const body = readBody(req);
|
||||||
|
const targetId = toStringOrEmpty(body.targetId) || undefined;
|
||||||
|
const clear = toBoolean(body.clear) ?? false;
|
||||||
|
const latitude = toNumber(body.latitude);
|
||||||
|
const longitude = toNumber(body.longitude);
|
||||||
|
const accuracy = toNumber(body.accuracy) ?? undefined;
|
||||||
|
const origin = toStringOrEmpty(body.origin) || undefined;
|
||||||
|
try {
|
||||||
|
const tab = await profileCtx.ensureTabAvailable(targetId);
|
||||||
|
const pw = await requirePwAi(res, "geolocation");
|
||||||
|
if (!pw) return;
|
||||||
|
await pw.setGeolocationViaPlaywright({
|
||||||
|
cdpUrl: profileCtx.profile.cdpUrl,
|
||||||
|
targetId: tab.targetId,
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
accuracy,
|
||||||
|
origin,
|
||||||
|
clear,
|
||||||
|
});
|
||||||
|
res.json({ ok: true, targetId: tab.targetId });
|
||||||
|
} catch (err) {
|
||||||
|
handleRouteError(ctx, res, err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/set/media", async (req, res) => {
|
||||||
|
const profileCtx = resolveProfileContext(req, res, ctx);
|
||||||
|
if (!profileCtx) return;
|
||||||
|
const body = readBody(req);
|
||||||
|
const targetId = toStringOrEmpty(body.targetId) || undefined;
|
||||||
|
const schemeRaw = toStringOrEmpty(body.colorScheme);
|
||||||
|
const colorScheme =
|
||||||
|
schemeRaw === "dark" ||
|
||||||
|
schemeRaw === "light" ||
|
||||||
|
schemeRaw === "no-preference"
|
||||||
|
? (schemeRaw as "dark" | "light" | "no-preference")
|
||||||
|
: schemeRaw === "none"
|
||||||
|
? null
|
||||||
|
: undefined;
|
||||||
|
if (colorScheme === undefined)
|
||||||
|
return jsonError(
|
||||||
|
res,
|
||||||
|
400,
|
||||||
|
"colorScheme must be dark|light|no-preference|none",
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
const tab = await profileCtx.ensureTabAvailable(targetId);
|
||||||
|
const pw = await requirePwAi(res, "media emulation");
|
||||||
|
if (!pw) return;
|
||||||
|
await pw.emulateMediaViaPlaywright({
|
||||||
|
cdpUrl: profileCtx.profile.cdpUrl,
|
||||||
|
targetId: tab.targetId,
|
||||||
|
colorScheme,
|
||||||
|
});
|
||||||
|
res.json({ ok: true, targetId: tab.targetId });
|
||||||
|
} catch (err) {
|
||||||
|
handleRouteError(ctx, res, err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/set/timezone", async (req, res) => {
|
||||||
|
const profileCtx = resolveProfileContext(req, res, ctx);
|
||||||
|
if (!profileCtx) return;
|
||||||
|
const body = readBody(req);
|
||||||
|
const targetId = toStringOrEmpty(body.targetId) || undefined;
|
||||||
|
const timezoneId = toStringOrEmpty(body.timezoneId);
|
||||||
|
if (!timezoneId) return jsonError(res, 400, "timezoneId is required");
|
||||||
|
try {
|
||||||
|
const tab = await profileCtx.ensureTabAvailable(targetId);
|
||||||
|
const pw = await requirePwAi(res, "timezone");
|
||||||
|
if (!pw) return;
|
||||||
|
await pw.setTimezoneViaPlaywright({
|
||||||
|
cdpUrl: profileCtx.profile.cdpUrl,
|
||||||
|
targetId: tab.targetId,
|
||||||
|
timezoneId,
|
||||||
|
});
|
||||||
|
res.json({ ok: true, targetId: tab.targetId });
|
||||||
|
} catch (err) {
|
||||||
|
handleRouteError(ctx, res, err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/set/locale", async (req, res) => {
|
||||||
|
const profileCtx = resolveProfileContext(req, res, ctx);
|
||||||
|
if (!profileCtx) return;
|
||||||
|
const body = readBody(req);
|
||||||
|
const targetId = toStringOrEmpty(body.targetId) || undefined;
|
||||||
|
const locale = toStringOrEmpty(body.locale);
|
||||||
|
if (!locale) return jsonError(res, 400, "locale is required");
|
||||||
|
try {
|
||||||
|
const tab = await profileCtx.ensureTabAvailable(targetId);
|
||||||
|
const pw = await requirePwAi(res, "locale");
|
||||||
|
if (!pw) return;
|
||||||
|
await pw.setLocaleViaPlaywright({
|
||||||
|
cdpUrl: profileCtx.profile.cdpUrl,
|
||||||
|
targetId: tab.targetId,
|
||||||
|
locale,
|
||||||
|
});
|
||||||
|
res.json({ ok: true, targetId: tab.targetId });
|
||||||
|
} catch (err) {
|
||||||
|
handleRouteError(ctx, res, err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/set/device", async (req, res) => {
|
||||||
|
const profileCtx = resolveProfileContext(req, res, ctx);
|
||||||
|
if (!profileCtx) return;
|
||||||
|
const body = readBody(req);
|
||||||
|
const targetId = toStringOrEmpty(body.targetId) || undefined;
|
||||||
|
const name = toStringOrEmpty(body.name);
|
||||||
|
if (!name) return jsonError(res, 400, "name is required");
|
||||||
|
try {
|
||||||
|
const tab = await profileCtx.ensureTabAvailable(targetId);
|
||||||
|
const pw = await requirePwAi(res, "device emulation");
|
||||||
|
if (!pw) return;
|
||||||
|
await pw.setDeviceViaPlaywright({
|
||||||
|
cdpUrl: profileCtx.profile.cdpUrl,
|
||||||
|
targetId: tab.targetId,
|
||||||
|
name,
|
||||||
|
});
|
||||||
|
res.json({ ok: true, targetId: tab.targetId });
|
||||||
|
} catch (err) {
|
||||||
|
handleRouteError(ctx, res, err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.post("/pdf", async (req, res) => {
|
app.post("/pdf", async (req, res) => {
|
||||||
const profileCtx = resolveProfileContext(req, res, ctx);
|
const profileCtx = resolveProfileContext(req, res, ctx);
|
||||||
if (!profileCtx) return;
|
if (!profileCtx) return;
|
||||||
@@ -577,6 +1116,7 @@ export function registerBrowserAgentRoutes(
|
|||||||
const compact = toBoolean(req.query.compact);
|
const compact = toBoolean(req.query.compact);
|
||||||
const depth = toNumber(req.query.depth);
|
const depth = toNumber(req.query.depth);
|
||||||
const selector = toStringOrEmpty(req.query.selector);
|
const selector = toStringOrEmpty(req.query.selector);
|
||||||
|
const frameSelector = toStringOrEmpty(req.query.frame);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const tab = await profileCtx.ensureTabAvailable(targetId || undefined);
|
const tab = await profileCtx.ensureTabAvailable(targetId || undefined);
|
||||||
@@ -587,13 +1127,15 @@ export function registerBrowserAgentRoutes(
|
|||||||
interactive === true ||
|
interactive === true ||
|
||||||
compact === true ||
|
compact === true ||
|
||||||
depth !== undefined ||
|
depth !== undefined ||
|
||||||
Boolean(selector.trim());
|
Boolean(selector.trim()) ||
|
||||||
|
Boolean(frameSelector.trim());
|
||||||
|
|
||||||
const snap = wantsRoleSnapshot
|
const snap = wantsRoleSnapshot
|
||||||
? await pw.snapshotRoleViaPlaywright({
|
? await pw.snapshotRoleViaPlaywright({
|
||||||
cdpUrl: profileCtx.profile.cdpUrl,
|
cdpUrl: profileCtx.profile.cdpUrl,
|
||||||
targetId: tab.targetId,
|
targetId: tab.targetId,
|
||||||
selector: selector.trim() || undefined,
|
selector: selector.trim() || undefined,
|
||||||
|
frameSelector: frameSelector.trim() || undefined,
|
||||||
options: {
|
options: {
|
||||||
interactive: interactive ?? undefined,
|
interactive: interactive ?? undefined,
|
||||||
compact: compact ?? undefined,
|
compact: compact ?? undefined,
|
||||||
@@ -613,6 +1155,7 @@ export function registerBrowserAgentRoutes(
|
|||||||
cdpUrl: profileCtx.profile.cdpUrl,
|
cdpUrl: profileCtx.profile.cdpUrl,
|
||||||
targetId: tab.targetId,
|
targetId: tab.targetId,
|
||||||
selector: selector.trim() || undefined,
|
selector: selector.trim() || undefined,
|
||||||
|
frameSelector: frameSelector.trim() || undefined,
|
||||||
options: {
|
options: {
|
||||||
interactive: interactive ?? undefined,
|
interactive: interactive ?? undefined,
|
||||||
compact: compact ?? undefined,
|
compact: compact ?? undefined,
|
||||||
|
|||||||
Reference in New Issue
Block a user