diff --git a/packages/injected/src/selectorGenerator.ts b/packages/injected/src/selectorGenerator.ts index bdacf765f2734..25de3c184d00b 100644 --- a/packages/injected/src/selectorGenerator.ts +++ b/packages/injected/src/selectorGenerator.ts @@ -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 }; diff --git a/packages/isomorphic/locatorGenerators.ts b/packages/isomorphic/locatorGenerators.ts index 40464bea33b7e..00d1e23f0facd 100644 --- a/packages/isomorphic/locatorGenerators.ts +++ b/packages/isomorphic/locatorGenerators.ts @@ -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 { diff --git a/packages/playwright-core/browsers.json b/packages/playwright-core/browsers.json index b2e9028bc9e8d..7d0d277bf2427 100644 --- a/packages/playwright-core/browsers.json +++ b/packages/playwright-core/browsers.json @@ -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", diff --git a/packages/playwright-core/src/server/webkit/protocol.d.ts b/packages/playwright-core/src/server/webkit/protocol.d.ts index 2703a24e3cf86..c29818c806c21 100644 --- a/packages/playwright-core/src/server/webkit/protocol.d.ts +++ b/packages/playwright-core/src/server/webkit/protocol.d.ts @@ -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. */ @@ -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; } diff --git a/packages/playwright-core/src/tools/backend/context.ts b/packages/playwright-core/src/tools/backend/context.ts index c1604bd83a2de..9a24a2ad0f927 100644 --- a/packages/playwright-core/src/tools/backend/context.ts +++ b/packages/playwright-core/src/tools/backend/context.ts @@ -45,6 +45,7 @@ export type ContextConfig = { blockedOrigins?: string[]; }; outputDir?: string; + outputMaxSize?: number; outputMode?: 'file' | 'stdout'; saveSession?: boolean; saveTrace?: boolean; diff --git a/packages/playwright-core/src/tools/backend/response.ts b/packages/playwright-core/src/tools/backend/response.ts index 1e287dc86a32f..db10fb696f5cd 100644 --- a/packages/playwright-core/src/tools/backend/response.ts +++ b/packages/playwright-core/src/tools/backend/response.ts @@ -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'; @@ -59,6 +61,7 @@ export class Response { private _imageResults: { data: Buffer, imageType: 'png' | 'jpeg' }[] = []; private _raw: boolean; private _json: boolean; + private _writtenFiles = new Set(); constructor(context: Context, toolName: string, toolArgs: Record, options?: { relativeTo?: string, raw?: boolean, json?: boolean }) { this._context = context; @@ -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) { @@ -163,6 +167,7 @@ export class Response { async serialize(): Promise { 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; @@ -225,6 +230,37 @@ export class Response { }; } + private async _enforceOutputBudget(): Promise { + 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 { const sections: Section[] = []; const addSection = (title: string, content: string[], codeframe?: 'yaml' | 'js') => { @@ -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 { const sections = new Map(); const sectionHeaders = text.split(/^### /m).slice(1); // Remove empty first element diff --git a/packages/playwright-core/src/tools/mcp/config.d.ts b/packages/playwright-core/src/tools/mcp/config.d.ts index 640084178c026..18bc3bc76f5de 100644 --- a/packages/playwright-core/src/tools/mcp/config.d.ts +++ b/packages/playwright-core/src/tools/mcp/config.d.ts @@ -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". diff --git a/packages/playwright-core/src/tools/mcp/config.ts b/packages/playwright-core/src/tools/mcp/config.ts index 0c33c134a1575..3f08edac21470 100644 --- a/packages/playwright-core/src/tools/mcp/config.ts +++ b/packages/playwright-core/src/tools/mcp/config.ts @@ -60,6 +60,7 @@ export type CLIOptions = { imageResponses?: 'allow' | 'omit'; sandbox?: boolean; outputDir?: string; + outputMaxSize?: number; port?: number; proxyBypass?: string; proxyServer?: string; @@ -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: { @@ -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); diff --git a/packages/playwright-core/src/tools/mcp/configIni.ts b/packages/playwright-core/src/tools/mcp/configIni.ts index 078295e7a9ae8..6198dcba6c7e3 100644 --- a/packages/playwright-core/src/tools/mcp/configIni.ts +++ b/packages/playwright-core/src/tools/mcp/configIni.ts @@ -161,6 +161,7 @@ const longhandTypes: Record = { 'saveVideo': 'size', 'sharedBrowserContext': 'boolean', 'outputDir': 'string', + 'outputMaxSize': 'number', 'imageResponses': 'string', 'allowUnrestrictedFileAccess': 'boolean', 'codegen': 'string', diff --git a/packages/playwright-core/src/tools/mcp/program.ts b/packages/playwright-core/src/tools/mcp/program.ts index ceca2400ae984..dd3aa7fa74652 100644 --- a/packages/playwright-core/src/tools/mcp/program.ts +++ b/packages/playwright-core/src/tools/mcp/program.ts @@ -59,6 +59,7 @@ export function decorateMCPCommand(command: Command) { .option('--image-responses ', '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 to the directory for output files.') + .option('--output-max-size ', 'Threshold for evicting old output files, in bytes.', numberParser) .option('--output-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 to listen on for SSE transport.') .option('--proxy-bypass ', 'comma-separated domains to bypass proxy, for example ".com,chromium.org,.domain.com"') diff --git a/tests/library/browsercontext-clearcookies.spec.ts b/tests/library/browsercontext-clearcookies.spec.ts index ba94f1b3279de..85fa41b9867e7 100644 --- a/tests/library/browsercontext-clearcookies.spec.ts +++ b/tests/library/browsercontext-clearcookies.spec.ts @@ -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', diff --git a/tests/library/locator-generator.spec.ts b/tests/library/locator-generator.spec.ts index ab95cdbaa60a2..fa9a863592e9c 100644 --- a/tests/library/locator-generator.spec.ts +++ b/tests/library/locator-generator.spec.ts @@ -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")', diff --git a/tests/library/selector-generator.spec.ts b/tests/library/selector-generator.spec.ts index 2b028e8a0bd9c..6d050cacf64bd 100644 --- a/tests/library/selector-generator.spec.ts +++ b/tests/library/selector-generator.spec.ts @@ -95,8 +95,8 @@ it.describe('selector generator', () => { `); - 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 }) => { @@ -114,8 +114,8 @@ it.describe('selector generator', () => { `); - 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 }) => { @@ -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(` + + + + + + + +
foobarbazfoobarbazfoobarbaz
foobarbazfoobarbazfoobarbaz
+ `); + 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(`
diff --git a/tests/mcp/output-max-size.spec.ts b/tests/mcp/output-max-size.spec.ts new file mode 100644 index 0000000000000..dc8e3c9ccc069 --- /dev/null +++ b/tests/mcp/output-max-size.spec.ts @@ -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('/', `D`, '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); +});