Skip to content
Draft
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
22 changes: 14 additions & 8 deletions packages/playwright-core/src/tools/backend/cookies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,13 @@ const cookieList = defineTool({
if (params.path)
cookies = cookies.filter(c => c.path.startsWith(params.path!));

if (cookies.length === 0)
response.addTextResult('No cookies found');
else
response.addTextResult(cookies.map(c => `${c.name}=${c.value} (domain: ${c.domain}, path: ${c.path})`).join('\n'));
if (response.json) {
response.setResultJSON(cookies);
return;
}
response.addTextResult(cookies.length === 0
? 'No cookies found'
: cookies.map(c => `${c.name}=${c.value} (domain: ${c.domain}, path: ${c.path})`).join('\n'));
response.addCode(`await page.context().cookies();`);
},
});
Expand All @@ -66,10 +69,13 @@ const cookieGet = defineTool({
const cookies = await browserContext.cookies();
const cookie = cookies.find(c => c.name === params.name);

if (!cookie)
response.addTextResult(`Cookie '${params.name}' not found`);
else
response.addTextResult(`${cookie.name}=${cookie.value} (domain: ${cookie.domain}, path: ${cookie.path}, httpOnly: ${cookie.httpOnly}, secure: ${cookie.secure}, sameSite: ${cookie.sameSite})`);
if (response.json) {
response.setResultJSON(cookie ?? null);
return;
}
response.addTextResult(cookie
? `${cookie.name}=${cookie.value} (domain: ${cookie.domain}, path: ${cookie.path}, httpOnly: ${cookie.httpOnly}, secure: ${cookie.secure}, sameSite: ${cookie.sameSite})`
: `Cookie '${params.name}' not found`);
response.addCode(`await page.context().cookies();`);
},
});
Expand Down
125 changes: 93 additions & 32 deletions packages/playwright-core/src/tools/backend/network.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { getExtensionForMimeType, isTextualMimeType } from '@isomorphic/mimeType
import { defineTool, defineTabTool } from './tool';

import type { Response as ToolResponse } from './response';
import type { HeadersArray } from '@isomorphic/types';
import type * as playwright from '../../..';

const requests = defineTabTool({
Expand Down Expand Up @@ -95,7 +96,12 @@ const request = defineTabTool({
await renderRequestPart(request, params.part, response, params.filename);
return;
}
await response.addResult('Request', renderRequestDetails(params.index, request, !!tab.context.config.skillMode), { prefix: 'request', ext: 'log', suggestedFilename: params.filename });
const details = await buildRequestDetails(params.index, request);
if (response.json && !params.filename) {
response.setResultJSON(details);
return;
}
await response.addResult('Request', renderRequestDetailsText(details, !!tab.context.config.skillMode), { prefix: 'request', ext: 'log', suggestedFilename: params.filename });
},
});

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

function renderRequestDetails(index: number, request: playwright.Request, skillMode: boolean): string {
type RequestDetails = {
index: number;
method: string;
url: string;
resourceType: string;
duration?: number;
mimeType?: string;
status?: number;
statusText?: string;
failure?: string;
requestHeaders: HeadersArray;
responseHeaders?: HeadersArray;
hasRequestBody: boolean;
hasResponseBody: boolean;
};

async function buildRequestDetails(index: number, request: playwright.Request): Promise<RequestDetails> {
const httpResponse = request.existingResponse();
const responseHeaders = httpResponse?.headers();
const [requestHeaders, responseHeaders] = await Promise.all([
request.headersArray(),
httpResponse?.headersArray(),
]);
const contentType = responseHeaders?.find(h => h.name.toLowerCase() === 'content-type')?.value;
return {
index,
method: request.method().toUpperCase(),
url: request.url(),
resourceType: request.resourceType(),
duration: computeDurationMs(request),
mimeType: contentType ? contentType.split(';')[0].trim() : undefined,
status: httpResponse?.status(),
statusText: httpResponse?.statusText(),
failure: request.failure()?.errorText,
requestHeaders,
responseHeaders,
hasRequestBody: request.postData() !== null,
hasResponseBody: canHaveResponseBody(httpResponse),
};
}

function renderRequestDetailsText(details: RequestDetails, skillMode: boolean): string {
const lines: string[] = [];
lines.push(`#${index} [${request.method().toUpperCase()}] ${request.url()}`);
lines.push(`#${details.index} [${details.method}] ${details.url}`);

lines.push('');
lines.push(' General');
if (httpResponse)
lines.push(` status: [${httpResponse.status()}] ${httpResponse.statusText()}`);
else if (request.failure())
lines.push(` status: [FAILED] ${request.failure()?.errorText ?? 'Unknown error'}`);
const duration = computeDurationMs(request);
if (duration !== undefined)
lines.push(` duration: ${duration}ms`);
lines.push(` type: ${request.resourceType()}`);
const contentType = responseHeaders?.['content-type'];
if (contentType)
lines.push(` mimeType: ${contentType.split(';')[0].trim()}`);
if (details.status !== undefined)
lines.push(` status: [${details.status}] ${details.statusText}`);
else if (details.failure)
lines.push(` status: [FAILED] ${details.failure}`);
if (details.duration !== undefined)
lines.push(` duration: ${details.duration}ms`);
lines.push(` type: ${details.resourceType}`);
if (details.mimeType)
lines.push(` mimeType: ${details.mimeType}`);

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

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

const hints: string[] = [];
if (request.postData())
hints.push(partHint(skillMode, 'request-body', index));
if (canHaveResponseBody(httpResponse))
hints.push(partHint(skillMode, 'response-body', index));
if (details.hasRequestBody)
hints.push(partHint(skillMode, 'request-body', details.index));
if (details.hasResponseBody)
hints.push(partHint(skillMode, 'response-body', details.index));
if (hints.length)
lines.push('', ...hints);

Expand All @@ -186,14 +228,13 @@ function canHaveResponseBody(httpResponse: playwright.Response | null): httpResp
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)
function appendHeaderSection(lines: string[], title: string, headers: HeadersArray): void {
if (!headers.length)
return;
lines.push('');
lines.push(` ${title}`);
for (const [k, v] of entries)
lines.push(` ${k}: ${v}`);
for (const { name, value } of headers)
lines.push(` ${name}: ${value}`);
}

function computeDurationMs(request: playwright.Request): number | undefined {
Expand All @@ -205,11 +246,20 @@ function computeDurationMs(request: playwright.Request): number | 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 });
const headers = await request.headersArray();
if (response.json && !suggestedFilename) {
response.setResultJSON(headers);
return;
}
await response.addResult('Request headers', renderHeaders(headers), { prefix: 'request', ext: 'txt', suggestedFilename });
return;
}
if (part === 'request-body') {
const data = request.postData();
if (response.json && !suggestedFilename) {
response.setResultJSON(data);
return;
}
if (data !== null)
await response.addResult('Request body', data, { prefix: 'request', ext: 'txt', suggestedFilename });
return;
Expand All @@ -218,7 +268,12 @@ async function renderRequestPart(request: playwright.Request, part: RequestPart,
if (!httpResponse)
return;
if (part === 'response-headers') {
await response.addResult('Response headers', renderHeaders(httpResponse.headers()), { prefix: 'response', ext: 'txt', suggestedFilename });
const headers = await httpResponse.headersArray();
if (response.json && !suggestedFilename) {
response.setResultJSON(headers);
return;
}
await response.addResult('Response headers', renderHeaders(headers), { prefix: 'response', ext: 'txt', suggestedFilename });
return;
}
// response-body
Expand All @@ -230,16 +285,22 @@ async function renderRequestPart(request: playwright.Request, part: RequestPart,
} catch {
return;
}
if (response.json && !suggestedFilename) {
response.setResultJSON(text);
return;
}
await response.addResult('Response body', text, { prefix: 'response', ext: 'txt', suggestedFilename });
return;
}
const path = await saveResponseBody(request, response, suggestedFilename);
if (path !== undefined)
if (path !== undefined) {
response.setResultJSON({ file: path });
response.addTextResult(path);
}
}

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

async function saveResponseBody(request: playwright.Request, response: ToolResponse, suggestedFilename?: string): Promise<string | undefined> {
Expand Down
12 changes: 12 additions & 0 deletions packages/playwright-core/src/tools/backend/response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export class Response {
private _imageResults: { data: Buffer, imageType: 'png' | 'jpeg' }[] = [];
private _raw: boolean;
private _json: boolean;
private _resultJSON: { value: unknown } | undefined;

constructor(context: Context, toolName: string, toolArgs: Record<string, any>, options?: { relativeTo?: string, raw?: boolean, json?: boolean }) {
this._context = context;
Expand All @@ -69,6 +70,10 @@ export class Response {
this._raw = this._json || (options?.raw ?? false);
}

get json(): boolean {
return this._json;
}

private _computeRelativeTo(fileName: string): string {
const rel = path.relative(this._clientWorkspace, fileName);
// Prefix bare filenames with `./` so they're not mistaken for living in
Expand Down Expand Up @@ -97,10 +102,15 @@ export class Response {
this._results.push(text);
}

setResultJSON(value: unknown) {
this._resultJSON = { value };
}

async addResult(title: string, data: Buffer | string, file: FilenameTemplate) {
if (file.suggestedFilename || typeof data !== 'string') {
const resolvedFile = await this.resolveClientFile(file, title);
await this.addFileResult(resolvedFile, data);
this.setResultJSON({ file: resolvedFile.relativeName });
} else {
this.addTextResult(data);
}
Expand Down Expand Up @@ -183,6 +193,8 @@ export class Response {
payload[key] = section.content.join('\n');
}
}
if (this._resultJSON !== undefined)
payload.result = this._resultJSON.value;
serializedText = JSON.stringify(payload, null, 2);
} else {
const text: string[] = [];
Expand Down
4 changes: 4 additions & 0 deletions packages/playwright-core/src/tools/backend/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,10 @@ const routeList = defineTool({

handle: async (context, params, response) => {
const routes = context.routes();
if (response.json) {
response.setResultJSON(routes.map(({ handler, ...rest }) => rest));
return;
}
if (routes.length === 0) {
response.addTextResult('No active routes');
return;
Expand Down
44 changes: 28 additions & 16 deletions packages/playwright-core/src/tools/backend/webstorage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,13 @@ const localStorageList = defineTabTool({
return result;
});

if (items.length === 0)
response.addTextResult('No localStorage items found');
else
response.addTextResult(items.map(item => `${item.key}=${item.value}`).join('\n'));
if (response.json) {
response.setResultJSON(items);
return;
}
response.addTextResult(items.length === 0
? 'No localStorage items found'
: items.map(item => `${item.key}=${item.value}`).join('\n'));
response.addCode(`await page.evaluate(() => ({ ...localStorage }));`);
},
});
Expand All @@ -63,10 +66,13 @@ const localStorageGet = defineTabTool({
handle: async (tab, params, response) => {
const value = await tab.page.evaluate(key => localStorage.getItem(key), params.key);

if (value === null)
response.addTextResult(`localStorage key '${params.key}' not found`);
else
response.addTextResult(`${params.key}=${value}`);
if (response.json) {
response.setResultJSON(value);
return;
}
response.addTextResult(value === null
? `localStorage key '${params.key}' not found`
: `${params.key}=${value}`);
response.addCode(`await page.evaluate(() => localStorage.getItem('${params.key}'));`);
},
});
Expand Down Expand Up @@ -151,10 +157,13 @@ const sessionStorageList = defineTabTool({
return result;
});

if (items.length === 0)
response.addTextResult('No sessionStorage items found');
else
response.addTextResult(items.map(item => `${item.key}=${item.value}`).join('\n'));
if (response.json) {
response.setResultJSON(items);
return;
}
response.addTextResult(items.length === 0
? 'No sessionStorage items found'
: items.map(item => `${item.key}=${item.value}`).join('\n'));
response.addCode(`await page.evaluate(() => ({ ...sessionStorage }));`);
},
});
Expand All @@ -175,10 +184,13 @@ const sessionStorageGet = defineTabTool({
handle: async (tab, params, response) => {
const value = await tab.page.evaluate(key => sessionStorage.getItem(key), params.key);

if (value === null)
response.addTextResult(`sessionStorage key '${params.key}' not found`);
else
response.addTextResult(`${params.key}=${value}`);
if (response.json) {
response.setResultJSON(value);
return;
}
response.addTextResult(value === null
? `sessionStorage key '${params.key}' not found`
: `${params.key}=${value}`);
response.addCode(`await page.evaluate(() => sessionStorage.getItem('${params.key}'));`);
},
});
Expand Down
12 changes: 6 additions & 6 deletions tests/mcp/cli-devtools.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,9 +102,9 @@ test('request shows full request and response details', async ({ cli, server })
expect(output).toContain('General');
expect(output).toContain('status: [200] OK');
expect(output).toContain('Request headers');
expect(output).toContain('x-custom-header: test-value');
expect(output).toMatch(/x-custom-header: test-value/i);
expect(output).toContain('Response headers');
expect(output).toContain('x-custom-response: response-value');
expect(output).toMatch(/x-custom-response: response-value/i);
expect(output).toContain(`Run \`request-body ${match![1]}\` to read the request body.`);
expect(output).toContain(`Run \`response-body ${match![1]}\` to read the response body.`);
expect(output).not.toContain('Request body');
Expand All @@ -129,9 +129,9 @@ test('per-part commands extract individual parts', async ({ cli, server }) => {
expect(match).not.toBeNull();
const num = match![1];

expect((await cli('request-headers', num)).output).toContain('x-custom-header: test-value');
expect((await cli('request-headers', num)).output).toMatch(/x-custom-header: test-value/i);
expect((await cli('request-body', num)).output).toContain('{"key":"value"}');
expect((await cli('response-headers', num)).output).toContain('x-custom-response: response-value');
expect((await cli('response-headers', num)).output).toMatch(/x-custom-response: response-value/i);
expect((await cli('response-body', num)).output).toContain('{"name":"John Doe"}');
});

Expand All @@ -158,13 +158,13 @@ test('request* and response* commands support --filename', async ({ cli, server
expect(read('req.log')).toContain(`[POST] ${server.PREFIX}/api`);

expect((await cli('request-headers', num, '--filename=req-h.txt')).output).toContain('[Request headers](./req-h.txt)');
expect(read('req-h.txt')).toContain('content-type: text/plain;charset=UTF-8');
expect(read('req-h.txt')).toMatch(/content-type: text\/plain;charset=UTF-8/i);

expect((await cli('request-body', num, '--filename=req-b.txt')).output).toContain('[Request body](./req-b.txt)');
expect(read('req-b.txt')).toBe('hello');

expect((await cli('response-headers', num, '--filename=res-h.txt')).output).toContain('[Response headers](./res-h.txt)');
expect(read('res-h.txt')).toContain('x-custom-response: response-value');
expect(read('res-h.txt')).toMatch(/x-custom-response: response-value/i);

expect((await cli('response-body', num, '--filename=res-b.json')).output).toContain('[Response body](./res-b.json)');
expect(read('res-b.json')).toBe('{"name":"John Doe"}');
Expand Down
Loading
Loading