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 54eb8c0cf6548..a05c0a1e253d5 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') => { @@ -329,6 +365,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 6756d9a731650..49f88d2a9ee48 100644 --- a/packages/playwright-core/src/tools/mcp/config.d.ts +++ b/packages/playwright-core/src/tools/mcp/config.d.ts @@ -154,6 +154,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 cc85e23f6bfb4..2583e2684d8ce 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; @@ -354,6 +355,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: { @@ -399,6 +401,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 37917055d2d58..d391037362e72 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/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); +});