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
2 changes: 1 addition & 1 deletion packages/injected/src/selectorGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -359,7 +359,7 @@ function buildTextCandidates(injectedScript: InjectedScript, element: Element, i
if (ariaDescription) {
candidates.push([{ engine: 'internal:role', selector: `${ariaRole}[name=${escapeForAttributeSelector(ariaName, true)}][description=${escapeForAttributeSelector(ariaDescription, true)}]`, score: kRoleWithNameScoreExact + 1 }]);
for (const alternative of suitableTextAlternatives(ariaName))
candidates.push([{ engine: 'internal:role', selector: `${ariaRole}[name=${escapeForAttributeSelector(alternative.text, false)}][description=${escapeForAttributeSelector(ariaDescription, true)}]`, score: kRoleWithNameScore - alternative.scoreBonus + 1 }]);
candidates.push([{ engine: 'internal:role', selector: `${ariaRole}[name=${escapeForAttributeSelector(alternative.text, false)}][description=${escapeForAttributeSelector(ariaDescription, false)}]`, score: kRoleWithNameScore - alternative.scoreBonus + 1 }]);
}
} else {
const roleToken = { engine: 'internal:role', selector: `${ariaRole}`, score: kRoleWithoutNameScore };
Expand Down
4 changes: 4 additions & 0 deletions packages/isomorphic/locatorGenerators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,9 +163,13 @@ function innerAsLocators(factory: LocatorFactory, parsed: ParsedSelector, isFram
const options: LocatorOptions = { attrs: [] };
for (const attr of attrSelector.attributes) {
if (attr.name === 'name') {
if (options.exact !== undefined && options.exact !== attr.caseSensitive)
throw new Error(`Conflicting exactness in internal:role selector: ${stringifySelector({ parts: [part] })}`);
options.exact = attr.caseSensitive;
options.name = attr.value;
} else if (attr.name === 'description') {
if (options.exact !== undefined && options.exact !== attr.caseSensitive)
throw new Error(`Conflicting exactness in internal:role selector: ${stringifySelector({ parts: [part] })}`);
options.exact = attr.caseSensitive;
options.description = attr.value;
} else {
Expand Down
6 changes: 3 additions & 3 deletions packages/playwright-core/browsers.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,14 +38,14 @@
},
{
"name": "firefox-beta",
"revision": "1516",
"revision": "1520",
"installByDefault": false,
"browserVersion": "151.0b7",
"browserVersion": "152.0b1",
"title": "Firefox Beta"
},
{
"name": "webkit",
"revision": "2297",
"revision": "2299",
"installByDefault": true,
"revisionOverrides": {
"mac14": "2251",
Expand Down
14 changes: 13 additions & 1 deletion packages/playwright-core/src/server/webkit/protocol.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6402,6 +6402,10 @@ the top of the viewport and Y increases as it proceeds towards the bottom of the
* Coordinate system used by supplied coordinates.
*/
export type CoordinateSystem = "Viewport"|"Page";
/**
* Image format used to encode a captured snapshot.
*/
export type ImageFormat = "png"|"jpeg"|"webp";
/**
* Same-Site policy of a cookie.
*/
Expand Down Expand Up @@ -6996,10 +7000,18 @@ the top of the viewport and Y increases as it proceeds towards the bottom of the
* By default, screenshot is inflated by device scale factor to avoid blurry image. This flag disables it.
*/
omitDeviceScaleFactor?: boolean;
/**
* Image format of the resulting snapshot. Defaults to "png".
*/
format?: ImageFormat;
/**
* Compression quality from 0 to 100 (ignored for the "png" format). Defaults to 80.
*/
quality?: number;
}
export type snapshotRectReturnValue = {
/**
* Base64-encoded image data (PNG).
* Base64-encoded image data.
*/
dataURL: string;
}
Expand Down
1 change: 1 addition & 0 deletions packages/playwright-core/src/tools/backend/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export type ContextConfig = {
blockedOrigins?: string[];
};
outputDir?: string;
outputMaxSize?: number;
outputMode?: 'file' | 'stdout';
saveSession?: boolean;
saveTrace?: boolean;
Expand Down
46 changes: 46 additions & 0 deletions packages/playwright-core/src/tools/backend/response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ import debug from 'debug';
import { renderModalStates } from './tab';
import { scaleImageToFitMessage } from './screenshot';

import { outputDir as resolveOutputDir } from './context';

import type * as playwright from '../../..';
import type { TabHeader } from './tab';
import type { CallToolResult, ImageContent, TextContent } from '@modelcontextprotocol/sdk/types.js';
Expand Down Expand Up @@ -59,6 +61,7 @@ export class Response {
private _imageResults: { data: Buffer, imageType: 'png' | 'jpeg' }[] = [];
private _raw: boolean;
private _json: boolean;
private _writtenFiles = new Set<string>();

constructor(context: Context, toolName: string, toolArgs: Record<string, any>, options?: { relativeTo?: string, raw?: boolean, json?: boolean }) {
this._context = context;
Expand Down Expand Up @@ -111,6 +114,7 @@ export class Response {
await fs.promises.writeFile(resolvedFile.fileName, this._redactSecrets(data), 'utf-8');
else if (data)
await fs.promises.writeFile(resolvedFile.fileName, data);
this._writtenFiles.add(path.resolve(resolvedFile.fileName));
}

async addFileResult(resolvedFile: ResolvedFile, data: Buffer | string | null) {
Expand Down Expand Up @@ -163,6 +167,7 @@ export class Response {

async serialize(): Promise<CallToolResult> {
const allSections = await this._build();
await this._enforceOutputBudget();
const rawSections = ['Error', 'Result', 'Snapshot'] as const;
const sections = this._raw ? allSections.filter(section => rawSections.includes(section.title as typeof rawSections[number])) : allSections;

Expand Down Expand Up @@ -225,6 +230,37 @@ export class Response {
};
}

private async _enforceOutputBudget(): Promise<void> {
const maxSize = this._context.config.outputMaxSize;
if (!maxSize)
return;
const dir = resolveOutputDir(this._context.options);
let entries: { path: string, size: number, mtimeMs: number }[];
try {
entries = await listFilesRecursive(dir);
} catch {
return;
}
let total = 0;
for (const e of entries)
total += e.size;
if (total <= maxSize)
return;
entries.sort((a, b) => a.mtimeMs - b.mtimeMs);
for (const entry of entries) {
if (total <= maxSize)
break;
if (this._writtenFiles.has(entry.path))
continue;
try {
await fs.promises.unlink(entry.path);
total -= entry.size;
} catch (error) {
requestDebug('output-budget unlink failed %s: %s', entry.path, error);
}
}
}

private async _build(): Promise<Section[]> {
const sections: Section[] = [];
const addSection = (title: string, content: string[], codeframe?: 'yaml' | 'js') => {
Expand Down Expand Up @@ -327,6 +363,16 @@ function sanitizeUnicode(text: string): string {
return text.toWellFormed?.() ?? text;
}

async function listFilesRecursive(dir: string): Promise<{ path: string, size: number, mtimeMs: number }[]> {
const entries = await fs.promises.readdir(dir, { recursive: true, withFileTypes: true });
const files = entries.filter(e => e.isFile());
return Promise.all(files.map(async e => {
const full = path.join(e.parentPath, e.name);
const { size, mtimeMs } = await fs.promises.stat(full);
return { path: full, size, mtimeMs };
}));
}

function parseSections(text: string): Map<string, string> {
const sections = new Map<string, string>();
const sectionHeaders = text.split(/^### /m).slice(1); // Remove empty first element
Expand Down
5 changes: 5 additions & 0 deletions packages/playwright-core/src/tools/mcp/config.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,11 @@ export type Config = {
*/
outputDir?: string;

/**
* Threshold for evicting old output files, in bytes.
*/
outputMaxSize?: number;

console?: {
/**
* The level of console messages to return. Each level includes the messages of more severe levels. Defaults to "info".
Expand Down
3 changes: 3 additions & 0 deletions packages/playwright-core/src/tools/mcp/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export type CLIOptions = {
imageResponses?: 'allow' | 'omit';
sandbox?: boolean;
outputDir?: string;
outputMaxSize?: number;
port?: number;
proxyBypass?: string;
proxyServer?: string;
Expand Down Expand Up @@ -356,6 +357,7 @@ function configFromCLIOptions(cliOptions: CLIOptions): Config & { configFile?: s
sharedBrowserContext: cliOptions.sharedBrowserContext,
snapshot: cliOptions.snapshotMode ? { mode: cliOptions.snapshotMode } : undefined,
outputDir: cliOptions.outputDir,
outputMaxSize: cliOptions.outputMaxSize,
imageResponses: cliOptions.imageResponses,
testIdAttribute: cliOptions.testIdAttribute,
timeouts: {
Expand Down Expand Up @@ -401,6 +403,7 @@ export function configFromEnv(env?: NodeJS.ProcessEnv): Config & { configFile?:
options.imageResponses = enumParser<'allow' | 'omit'>('--image-responses', ['allow', 'omit'], e.PLAYWRIGHT_MCP_IMAGE_RESPONSES);
options.sandbox = envToBoolean(e.PLAYWRIGHT_MCP_SANDBOX);
options.outputDir = envToString(e.PLAYWRIGHT_MCP_OUTPUT_DIR);
options.outputMaxSize = numberParser(e.PLAYWRIGHT_MCP_OUTPUT_MAX_SIZE);
options.port = numberParser(e.PLAYWRIGHT_MCP_PORT);
options.proxyBypass = envToString(e.PLAYWRIGHT_MCP_PROXY_BYPASS);
options.proxyServer = envToString(e.PLAYWRIGHT_MCP_PROXY_SERVER);
Expand Down
1 change: 1 addition & 0 deletions packages/playwright-core/src/tools/mcp/configIni.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ const longhandTypes: Record<string, LonghandType> = {
'saveVideo': 'size',
'sharedBrowserContext': 'boolean',
'outputDir': 'string',
'outputMaxSize': 'number',
'imageResponses': 'string',
'allowUnrestrictedFileAccess': 'boolean',
'codegen': 'string',
Expand Down
1 change: 1 addition & 0 deletions packages/playwright-core/src/tools/mcp/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export function decorateMCPCommand(command: Command) {
.option('--image-responses <mode>', 'whether to send image responses to the client. Can be "allow" or "omit", Defaults to "allow".', enumParser.bind(null, '--image-responses', ['allow', 'omit']))
.option('--no-sandbox', 'disable the sandbox for all process types that are normally sandboxed.')
.option('--output-dir <path>', 'path to the directory for output files.')
.option('--output-max-size <bytes>', 'Threshold for evicting old output files, in bytes.', numberParser)
.option('--output-mode <mode>', 'whether to save snapshots, console messages, network logs to a file or to the standard output. Can be "file" or "stdout". Default is "stdout".', enumParser.bind(null, '--output-mode', ['file', 'stdout']))
.option('--port <port>', 'port to listen on for SSE transport.')
.option('--proxy-bypass <bypass>', 'comma-separated domains to bypass proxy, for example ".com,chromium.org,.domain.com"')
Expand Down
3 changes: 2 additions & 1 deletion tests/library/browsercontext-clearcookies.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,8 @@ it('should remove cookies by name and domain', async ({ context, page, server })

it('should not transiently delete non-matching cookies when filtering', {
annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/40953' },
}, async ({ context, page, server }) => {
}, async ({ context, page, server, browserName, isWindows }) => {
it.skip(browserName === 'webkit' && isWindows, 'cookieStore change events not supported on WebKit/Windows (curl backend lacks cookie change notifications)');
await context.addCookies([{
name: 'keep_me',
value: '1',
Expand Down
11 changes: 11 additions & 0 deletions tests/library/locator-generator.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,17 @@ it('reverse engineer getByRole', async ({ page }) => {
});
});

it('refuses to translate internal:role with conflicting name/description exactness', {
annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/41032' }
}, async () => {
const conflicting = 'internal:role=row[name="abc"i][description="d"s]';
const conflictingReversed = 'internal:role=row[name="abc"s][description="d"i]';
for (const lang of ['javascript', 'python', 'java', 'csharp'] as const) {
expect.soft(asLocator(lang, conflicting), lang).toBe(conflicting);
expect.soft(asLocator(lang, conflictingReversed), lang).toBe(conflictingReversed);
}
});

it('reverse engineer ignore-case locators', async ({ page }) => {
expect.soft(generate(page.getByText('hello my\nwo"rld'))).toEqual({
csharp: 'GetByText("hello my\\nwo\\"rld")',
Expand Down
30 changes: 26 additions & 4 deletions tests/library/selector-generator.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,8 +95,8 @@ it.describe('selector generator', () => {
<button aria-description="Upload report">Submit</button>
<button aria-description="Upload photo">Submit</button>
`);
expect(await generate(page, 'button[aria-description="Upload report"]')).toBe('internal:role=button[name="Submit"i][description="Upload report"s]');
expect(await generate(page, 'button[aria-description="Upload photo"]')).toBe('internal:role=button[name="Submit"i][description="Upload photo"s]');
expect(await generate(page, 'button[aria-description="Upload report"]')).toBe('internal:role=button[name="Submit"i][description="Upload report"i]');
expect(await generate(page, 'button[aria-description="Upload photo"]')).toBe('internal:role=button[name="Submit"i][description="Upload photo"i]');
});

it('should not use description when name is unique', async ({ page }) => {
Expand All @@ -114,8 +114,8 @@ it.describe('selector generator', () => {
<button aria-describedby="desc1">Submit</button>
<button aria-describedby="desc2">Submit</button>
`);
expect(await generate(page, 'button[aria-describedby="desc1"]')).toBe('internal:role=button[name="Submit"i][description="Save form data"s]');
expect(await generate(page, 'button[aria-describedby="desc2"]')).toBe('internal:role=button[name="Submit"i][description="Save as draft"s]');
expect(await generate(page, 'button[aria-describedby="desc1"]')).toBe('internal:role=button[name="Submit"i][description="Save form data"i]');
expect(await generate(page, 'button[aria-describedby="desc2"]')).toBe('internal:role=button[name="Submit"i][description="Save as draft"i]');
});

it('should fall back to nth when name and description are both not unique', async ({ page }) => {
Expand All @@ -126,6 +126,28 @@ it.describe('selector generator', () => {
expect(await generate(page, 'button:first-of-type')).toBe('internal:role=button[name="Submit"i] >> nth=0');
});

it('should use consistent exactness for name and description', {
annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/41032' }
}, async ({ page }) => {
// The public getByRole API has a single `exact` flag that covers both
// `name` and `description`, so the producer must not emit a candidate
// that mixes substring name with exact description (or vice versa).
await page.setContent(`
<table>
<tr title="table row 1">
<td>foo</td><td>bar</td><td>baz</td><td>foo</td><td>bar</td><td>baz</td><td>foo</td><td>bar</td><td>baz</td>
</tr>
<tr title="table row 2">
<td>foo</td><td>bar</td><td>baz</td><td>foo</td><td>bar</td><td>baz</td><td>foo</td><td>bar</td><td>baz</td>
</tr>
</table>
`);
const selector = await generate(page, 'tr[title="table row 1"]');
// Both name and description must share the same case-sensitivity flag.
expect(selector).not.toMatch(/\[name="[^"]*"i\]\[description="[^"]*"s\]/);
expect(selector).not.toMatch(/\[name="[^"]*"s\]\[description="[^"]*"i\]/);
});

it('should use description when role has no name', async ({ page }) => {
await page.setContent(`
<div role="alert" aria-description="Error in field A"></div>
Expand Down
71 changes: 71 additions & 0 deletions tests/mcp/output-max-size.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import fs from 'fs';
import path from 'path';

import { test, expect } from './fixtures';

test('evicts oldest evictable files before write exceeds cap, pinned session.md survives', async ({ startClient, server }, testInfo) => {
const outputDir = testInfo.outputPath('output');
const { client } = await startClient({
config: { outputDir, saveSession: true, outputMaxSize: 5_000 },
});

let n = 0;
server.setRoute('/download', (req, res) => {
res.writeHead(200, {
'Content-Type': 'application/octet-stream',
'Content-Disposition': `attachment; filename=file-${n++}.bin`,
});
res.end(Buffer.alloc(1000, 'x'));
});
server.setContent('/', `<!doctype html><body><a href="/download" download>D</a></body>`, 'text/html');

await client.callTool({ name: 'browser_navigate', arguments: { url: server.PREFIX } });
for (let i = 0; i < 8; i++) {
await client.callTool({ name: 'browser_click', arguments: { element: 'D', target: 'e2' } });
// Click returns before download.saveAs() completes; wait for the file before the next eviction.
await expect.poll(() => fs.existsSync(path.join(outputDir, `file-${i}.bin`))).toBe(true);
}
// One more tool call so eviction runs with all 8 downloads on disk.
await client.callTool({ name: 'browser_snapshot' });

const bins = fs.readdirSync(outputDir).filter(f => f.endsWith('.bin'));
expect(bins.length).toBeLessThan(8);
expect(bins.reduce((acc, f) => acc + fs.statSync(path.join(outputDir, f)).size, 0)).toBeLessThanOrEqual(5_000);

const sessionFolder = fs.readdirSync(outputDir).find(e => e.startsWith('session-'));
expect(sessionFolder).toBeTruthy();
expect(fs.existsSync(path.join(outputDir, sessionFolder!, 'session.md'))).toBe(true);
});

test('oversize single file evicts everything and still writes', async ({ startClient, server }, testInfo) => {
const outputDir = testInfo.outputPath('output');
const { client } = await startClient({
config: {
outputDir,
outputMaxSize: 100,
},
});

await client.callTool({ name: 'browser_navigate', arguments: { url: server.HELLO_WORLD } });

await client.callTool({ name: 'browser_take_screenshot' });
await client.callTool({ name: 'browser_take_screenshot' });

expect(fs.readdirSync(outputDir)).toHaveLength(1);
});
Loading