Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions packages/playwright-core/src/server/bidi/bidiPage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -517,7 +517,7 @@ export class BidiPage implements PageDelegate {

async getBoundingBox(handle: dom.ElementHandle): Promise<types.Rect | null> {
const box = await handle.evaluate(element => {
if (!(element instanceof Element))
if (!(element instanceof Element) || element.getClientRects().length === 0)
return null;
const rect = element.getBoundingClientRect();
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
Expand Down Expand Up @@ -615,7 +615,11 @@ export class BidiPage implements PageDelegate {
const fromContext = toBidiExecutionContext(handle._context);
const nodeId = await fromContext.nodeIdForElementHandle(handle);
const executionContext = toBidiExecutionContext(to);
return await executionContext.remoteObjectForNodeId(to, nodeId) as dom.ElementHandle<T>;
try {
return await executionContext.remoteObjectForNodeId(to, nodeId) as dom.ElementHandle<T>;
} catch {
throw new Error(dom.kUnableToAdoptErrorMessage);
}
}

async inputActionEpilogue(): Promise<void> {
Expand Down
98 changes: 61 additions & 37 deletions packages/playwright-core/src/tools/backend/network.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,17 +44,24 @@ const requests = defineTabTool({
const allRequests = await tab.requests();
const filter = params.filter ? new RegExp(params.filter) : undefined;
const lines: string[] = [];
let hiddenStaticCount = 0;
for (let i = 0; i < allRequests.length; i++) {
const request = allRequests[i];
if (!params.static && !isFetch(request) && isSuccessfulResponse(request))
if (!params.static && !isFetch(request) && isSuccessfulResponse(request)) {
hiddenStaticCount++;
continue;
}
if (filter) {
filter.lastIndex = 0;
if (!filter.test(request.url()))
continue;
}
lines.push(`${i + 1}. ${renderRequestLine(request)}`);
}
if (hiddenStaticCount > 0) {
const optionName = tab.context.config.skillMode ? '--static' : '"static"';
lines.push(`\nNote: ${hiddenStaticCount} static request${hiddenStaticCount === 1 ? '' : 's'} not shown, run with ${optionName} option to see ${hiddenStaticCount === 1 ? 'it' : 'them'}.`);
}
await response.addResult('Network', lines.join('\n'), { prefix: 'network', ext: 'log', suggestedFilename: params.filename });
},
});
Expand All @@ -72,6 +79,7 @@ const request = defineTabTool({
inputSchema: z.object({
index: z.number().int().min(1).describe('1-based index of the request, as printed by browser_network_requests.'),
part: z.enum(REQUEST_PARTS).optional().describe('Return only this part of the request. Omit to return full details.'),
filename: z.string().optional().describe('Filename to save the result to. If not provided, output is returned as text.'),
}),
type: 'readOnly',
},
Expand All @@ -84,13 +92,10 @@ const request = defineTabTool({
return;
}
if (params.part) {
const partText = await renderRequestPart(request, params.part, response);
if (partText !== undefined)
response.addTextResult(partText);
await renderRequestPart(request, params.part, response, params.filename);
return;
}
const bodyPath = await saveResponseBody(request, response);
response.addTextResult(renderRequestDetails(params.index, request, bodyPath));
await response.addResult('Request', renderRequestDetails(params.index, request, !!tab.context.config.skillMode), { prefix: 'request', ext: 'log', suggestedFilename: params.filename });
},
});

Expand Down Expand Up @@ -130,7 +135,7 @@ export function renderRequestLine(request: playwright.Request): string {
return line;
}

function renderRequestDetails(index: number, request: playwright.Request, responseBodyPath: string | undefined): string {
function renderRequestDetails(index: number, request: playwright.Request, skillMode: boolean): string {
const httpResponse = request.existingResponse();
const responseHeaders = httpResponse?.headers();
const lines: string[] = [];
Expand All @@ -152,25 +157,35 @@ function renderRequestDetails(index: number, request: playwright.Request, respon

appendHeaderSection(lines, 'Request headers', request.headers());

const postData = request.postData();
if (postData) {
lines.push('');
lines.push(' Request body');
lines.push(` ${postData}`);
}

if (responseHeaders)
appendHeaderSection(lines, 'Response headers', responseHeaders);

if (responseBodyPath) {
lines.push('');
lines.push(' Response body');
lines.push(` ${responseBodyPath}`);
}
const hints: string[] = [];
if (request.postData())
hints.push(partHint(skillMode, 'request-body', index));
if (canHaveResponseBody(httpResponse))
hints.push(partHint(skillMode, 'response-body', index));
if (hints.length)
lines.push('', ...hints);

return lines.join('\n');
}

function partHint(skillMode: boolean, part: 'request-body' | 'response-body', index: number): string {
const subject = part === 'request-body' ? 'request body' : 'response body';
return skillMode
? `Run \`${part} ${index}\` to read the ${subject}.`
: `Call browser_network_request with part="${part}" to read the ${subject}.`;
}

function canHaveResponseBody(httpResponse: playwright.Response | null): httpResponse is playwright.Response {
if (!httpResponse)
return false;
const status = httpResponse.status();
// Status codes that cannot have a response body per RFC 7230.
return status !== 204 && status !== 304 && !(status >= 100 && status < 200);
}

function appendHeaderSection(lines: string[], title: string, headers: Record<string, string>): void {
const entries = Object.entries(headers);
if (!entries.length)
Expand All @@ -188,39 +203,48 @@ function computeDurationMs(request: playwright.Request): number | undefined {
return Math.round(timing.responseEnd);
}

async function renderRequestPart(request: playwright.Request, part: RequestPart, response: ToolResponse): Promise<string | undefined> {
if (part === 'request-headers')
return renderHeaders(request.headers());
if (part === 'request-body')
return request.postData() ?? undefined;
async function renderRequestPart(request: playwright.Request, part: RequestPart, response: ToolResponse, suggestedFilename: string | undefined): Promise<void> {
if (part === 'request-headers') {
await response.addResult('Request headers', renderHeaders(request.headers()), { prefix: 'request', ext: 'txt', suggestedFilename });
return;
}
if (part === 'request-body') {
const data = request.postData();
if (data !== null)
await response.addResult('Request body', data, { prefix: 'request', ext: 'txt', suggestedFilename });
return;
}
const httpResponse = request.existingResponse();
if (!httpResponse)
return undefined;
if (part === 'response-headers')
return renderHeaders(httpResponse.headers());
return;
if (part === 'response-headers') {
await response.addResult('Response headers', renderHeaders(httpResponse.headers()), { prefix: 'response', ext: 'txt', suggestedFilename });
return;
}
// response-body
const contentType = httpResponse.headers()['content-type'];
if (isTextualMimeType(contentType ?? '')) {
let text: string;
try {
return await httpResponse.text();
text = await httpResponse.text();
} catch {
return undefined;
return;
}
await response.addResult('Response body', text, { prefix: 'response', ext: 'txt', suggestedFilename });
return;
}
return await saveResponseBody(request, response);
const path = await saveResponseBody(request, response, suggestedFilename);
if (path !== undefined)
response.addTextResult(path);
}

function renderHeaders(headers: Record<string, string>): string {
return Object.entries(headers).map(([k, v]) => `${k}: ${v}`).join('\n');
}

async function saveResponseBody(request: playwright.Request, response: ToolResponse): Promise<string | undefined> {
async function saveResponseBody(request: playwright.Request, response: ToolResponse, suggestedFilename?: string): Promise<string | undefined> {
const httpResponse = request.existingResponse();
if (!httpResponse)
return undefined;
const status = httpResponse.status();
// Status codes that cannot have a response body per RFC 7230.
if (status === 204 || status === 304 || (status >= 100 && status < 200))
if (!canHaveResponseBody(httpResponse))
return undefined;
let body: Buffer;
try {
Expand All @@ -231,7 +255,7 @@ async function saveResponseBody(request: playwright.Request, response: ToolRespo
if (!body.length)
return undefined;
const ext = getExtensionForMimeType(httpResponse.headers()['content-type']);
const resolved = await response.resolveClientFile({ prefix: 'response', ext }, 'Response body');
const resolved = await response.resolveClientFile({ prefix: 'response', ext, suggestedFilename }, 'Response body');
await fs.promises.writeFile(resolved.fileName, body);
return resolved.relativeName;
}
Expand Down
2 changes: 2 additions & 0 deletions packages/playwright-core/src/tools/cli-client/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,8 @@ export async function program(options?: { embedderVersion?: string}) {
const entry = registry.entry(clientInfo, sessionName);
if (!entry)
output.errorBrowserNotOpenForTool(sessionName);
if (command.raw)
args.raw = true;
const text = await runInSession(entry, clientInfo, args, output);
output.toolResult(text);
}
Expand Down
1 change: 1 addition & 0 deletions packages/playwright-core/src/tools/cli-daemon/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export type CommandSchema<Args extends zodType.ZodTypeAny, Options extends zodTy
category: Category;
description: string;
hidden?: boolean;
raw?: boolean;
args?: Args;
options?: Options;
toolName: string | ((args: zodType.infer<Args> & zodType.infer<Options>) => string);
Expand Down
38 changes: 33 additions & 5 deletions packages/playwright-core/src/tools/cli-daemon/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -543,6 +543,7 @@ const cookieList = declareCommand({
name: 'cookie-list',
description: 'List all cookies (optionally filtered by domain/path)',
category: 'storage',
raw: true,
args: z.object({}),
options: z.object({
domain: z.string().optional().describe('Filter cookies by domain'),
Expand All @@ -556,6 +557,7 @@ const cookieGet = declareCommand({
name: 'cookie-get',
description: 'Get a specific cookie by name',
category: 'storage',
raw: true,
args: z.object({
name: z.string().describe('Cookie name'),
}),
Expand Down Expand Up @@ -609,6 +611,7 @@ const localStorageList = declareCommand({
name: 'localstorage-list',
description: 'List all localStorage key-value pairs',
category: 'storage',
raw: true,
args: z.object({}),
toolName: 'browser_localstorage_list',
toolParams: () => ({}),
Expand All @@ -618,6 +621,7 @@ const localStorageGet = declareCommand({
name: 'localstorage-get',
description: 'Get a localStorage item by key',
category: 'storage',
raw: true,
args: z.object({
key: z.string().describe('Key to get'),
}),
Expand Down Expand Up @@ -663,6 +667,7 @@ const sessionStorageList = declareCommand({
name: 'sessionstorage-list',
description: 'List all sessionStorage key-value pairs',
category: 'storage',
raw: true,
args: z.object({}),
toolName: 'browser_sessionstorage_list',
toolParams: () => ({}),
Expand All @@ -672,6 +677,7 @@ const sessionStorageGet = declareCommand({
name: 'sessionstorage-get',
description: 'Get a sessionStorage item by key',
category: 'storage',
raw: true,
args: z.object({
key: z.string().describe('Key to get'),
}),
Expand Down Expand Up @@ -742,6 +748,7 @@ const routeList = declareCommand({
name: 'route-list',
description: 'List all active network routes',
category: 'network',
raw: true,
args: z.object({}),
toolName: 'browser_route_list',
toolParams: () => ({}),
Expand Down Expand Up @@ -828,59 +835,80 @@ const networkRequests = declareCommand({
toolParams: ({ static: s, filter, clear }) => clear ? ({}) : ({ static: s, filter }),
});

const filenameOption = z.string().optional().describe('Filename to save the result to. If not provided, output is returned as text.');

const networkRequest = declareCommand({
name: 'request',
description: 'Show full details (headers, body, response) of a single network request by its number from the `requests` command.',
category: 'network',
args: z.object({
index: numberArg.describe('1-based number of the request as listed by `requests`'),
}),
options: z.object({
filename: filenameOption,
}),
toolName: 'browser_network_request',
toolParams: ({ index }) => ({ index }),
toolParams: ({ index, filename }) => ({ index, filename }),
});

const networkRequestHeaders = declareCommand({
name: 'request-headers',
description: 'Print only the request headers for a single network request by its number from the `requests` command.',
category: 'network',
raw: true,
args: z.object({
index: numberArg.describe('1-based number of the request as listed by `requests`'),
}),
options: z.object({
filename: filenameOption,
}),
toolName: 'browser_network_request',
toolParams: ({ index }) => ({ index, part: 'request-headers' }),
toolParams: ({ index, filename }) => ({ index, part: 'request-headers', filename }),
});

const networkRequestBody = declareCommand({
name: 'request-body',
description: 'Print only the request body for a single network request by its number from the `requests` command.',
category: 'network',
raw: true,
args: z.object({
index: numberArg.describe('1-based number of the request as listed by `requests`'),
}),
options: z.object({
filename: filenameOption,
}),
toolName: 'browser_network_request',
toolParams: ({ index }) => ({ index, part: 'request-body' }),
toolParams: ({ index, filename }) => ({ index, part: 'request-body', filename }),
});

const networkResponseHeaders = declareCommand({
name: 'response-headers',
description: 'Print only the response headers for a single network request by its number from the `requests` command.',
category: 'network',
raw: true,
args: z.object({
index: numberArg.describe('1-based number of the request as listed by `requests`'),
}),
options: z.object({
filename: filenameOption,
}),
toolName: 'browser_network_request',
toolParams: ({ index }) => ({ index, part: 'response-headers' }),
toolParams: ({ index, filename }) => ({ index, part: 'response-headers', filename }),
});

const networkResponseBody = declareCommand({
name: 'response-body',
description: 'Print the response body for a single network request by its number from the `requests` command. Textual bodies are inlined; binary bodies are saved to a file and the path is printed.',
category: 'network',
raw: true,
args: z.object({
index: numberArg.describe('1-based number of the request as listed by `requests`'),
}),
options: z.object({
filename: filenameOption,
}),
toolName: 'browser_network_request',
toolParams: ({ index }) => ({ index, part: 'response-body' }),
toolParams: ({ index, filename }) => ({ index, part: 'response-body', filename }),
});

const tracingStart = declareCommand({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ function isBooleanSchema(schema: zodType.ZodTypeAny): boolean {
export function generateHelpJSON() {
const booleanOptions = new Set<string>();

const commandEntries: Record<string, { help: string, flags: Record<string, 'boolean' | 'string'> }> = {};
const commandEntries: Record<string, { help: string, flags: Record<string, 'boolean' | 'string'>, raw?: boolean }> = {};
for (const [name, command] of Object.entries(commands)) {
const flags: Record<string, 'boolean' | 'string'> = {};
if (command.options) {
Expand All @@ -175,6 +175,8 @@ export function generateHelpJSON() {
}
}
commandEntries[name] = { help: generateCommandHelp(command), flags };
if (command.raw)
commandEntries[name].raw = true;
}

return {
Expand Down
Loading
Loading