diff --git a/packages/playwright-core/src/tools/backend/files.ts b/packages/playwright-core/src/tools/backend/files.ts index b7e965af6e294..44e1f5ee6badd 100644 --- a/packages/playwright-core/src/tools/backend/files.ts +++ b/packages/playwright-core/src/tools/backend/files.ts @@ -39,15 +39,14 @@ export const uploadFile = defineTabTool({ if (!modalState) throw new Error('No file chooser visible'); - if (params.paths) - await Promise.all(params.paths.map(filePath => response.resolveClientFilename(filePath))); + const paths = params.paths ? await Promise.all(params.paths.map(filePath => response.resolveClientFilename(filePath))) : undefined; - response.addCode(`await fileChooser.setFiles(${JSON.stringify(params.paths)})`); + response.addCode(`await fileChooser.setFiles(${JSON.stringify(paths)})`); tab.clearModalState(modalState); await tab.waitForCompletion(async () => { - if (params.paths) - await modalState.fileChooser.setFiles(params.paths); + if (paths) + await modalState.fileChooser.setFiles(paths); }); }, @@ -75,12 +74,11 @@ export const drop = defineTabTool({ response.setIncludeSnapshot(); const { locator, resolved } = await tab.targetLocator(params); - if (params.paths) - await Promise.all(params.paths.map(p => response.resolveClientFilename(p))); + const paths = params.paths ? await Promise.all(params.paths.map(p => response.resolveClientFilename(p))) : undefined; const payload: { files?: string | string[], data?: Record } = {}; - if (params.paths?.length) - payload.files = params.paths.length === 1 ? params.paths[0] : params.paths; + if (paths?.length) + payload.files = paths.length === 1 ? paths[0] : paths; if (params.data) payload.data = params.data; diff --git a/packages/playwright-core/src/tools/backend/network.ts b/packages/playwright-core/src/tools/backend/network.ts index ded33eb582207..f2ba099099c06 100644 --- a/packages/playwright-core/src/tools/backend/network.ts +++ b/packages/playwright-core/src/tools/backend/network.ts @@ -257,7 +257,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, suggestedFilename }, 'Response body'); + const resolved = await response.resolveClientOutputFile({ prefix: 'response', ext, suggestedFilename }, 'Response body'); await fs.promises.writeFile(resolved.fileName, body); return resolved.relativeName; } diff --git a/packages/playwright-core/src/tools/backend/pdf.ts b/packages/playwright-core/src/tools/backend/pdf.ts index a31a3569fda1e..19a0a8ce4f5a4 100644 --- a/packages/playwright-core/src/tools/backend/pdf.ts +++ b/packages/playwright-core/src/tools/backend/pdf.ts @@ -36,7 +36,7 @@ const pdf = defineTabTool({ handle: async (tab, params, response) => { const data = await tab.page.pdf(); - const result = await response.resolveClientFile({ prefix: 'page', ext: 'pdf', suggestedFilename: params.filename }, 'Page as pdf'); + const result = await response.resolveClientOutputFile({ prefix: 'page', ext: 'pdf', suggestedFilename: params.filename }, 'Page as pdf'); await response.addFileResult(result, data); response.addCode(`await page.pdf(${formatObject({ path: result.relativeName })});`); }, diff --git a/packages/playwright-core/src/tools/backend/response.ts b/packages/playwright-core/src/tools/backend/response.ts index b5315a40c341e..d7e2563fc95cd 100644 --- a/packages/playwright-core/src/tools/backend/response.ts +++ b/packages/playwright-core/src/tools/backend/response.ts @@ -85,12 +85,13 @@ export class Response { return rel; } - async resolveClientFile(template: FilenameTemplate, title: string): Promise { + async resolveClientOutputFile(template: FilenameTemplate, title: string): Promise { let fileName: string; if (template.suggestedFilename) fileName = await this.resolveClientFilename(template.suggestedFilename); else fileName = await this._context.outputFile(template, { origin: 'llm' }); + await fs.promises.mkdir(path.dirname(fileName), { recursive: true }); const relativeName = this._computeRelativeTo(fileName); const printableLink = `- [${title}](${relativeName})`; return { fileName, relativeName, printableLink }; @@ -106,7 +107,7 @@ export class Response { async addResult(title: string, data: Buffer | string, file: FilenameTemplate) { if (file.suggestedFilename || typeof data !== 'string') { - const resolvedFile = await this.resolveClientFile(file, title); + const resolvedFile = await this.resolveClientOutputFile(file, title); await this.addFileResult(resolvedFile, data); } else { this.addTextResult(data); @@ -304,7 +305,7 @@ export class Response { if (tabSnapshot && this._includeSnapshot !== 'none') { if (snapshotToFile) { const suggestedFilename = this._includeSnapshotFileName === '' ? undefined : this._includeSnapshotFileName; - const resolvedFile = await this.resolveClientFile({ prefix: 'page', ext: 'yml', suggestedFilename }, 'Snapshot'); + const resolvedFile = await this.resolveClientOutputFile({ prefix: 'page', ext: 'yml', suggestedFilename }, 'Snapshot'); await this._writeFile(resolvedFile, tabSnapshot.ariaSnapshot); addSection('Snapshot', [resolvedFile.printableLink]); } else if (tabSnapshot.ariaSnapshotJSON !== undefined) { diff --git a/packages/playwright-core/src/tools/backend/screenshot.ts b/packages/playwright-core/src/tools/backend/screenshot.ts index af0cf5e0cf706..140704ef58fbd 100644 --- a/packages/playwright-core/src/tools/backend/screenshot.ts +++ b/packages/playwright-core/src/tools/backend/screenshot.ts @@ -72,7 +72,7 @@ const screenshot = defineTabTool({ const target = params.target ? await tab.targetLocator({ element: params.element, target: params.target }) : null; const data = target ? await target.locator.screenshot(options) : await tab.page.screenshot(options); - const resolvedFile = await response.resolveClientFile({ prefix: target ? 'element' : 'page', ext: fileType, suggestedFilename: params.filename }, `Screenshot of ${screenshotTargetLabel}`); + const resolvedFile = await response.resolveClientOutputFile({ prefix: target ? 'element' : 'page', ext: fileType, suggestedFilename: params.filename }, `Screenshot of ${screenshotTargetLabel}`); response.addCode(`// Screenshot ${screenshotTargetLabel} and save it as ${resolvedFile.relativeName}`); if (target) diff --git a/packages/playwright-core/src/tools/backend/storage.ts b/packages/playwright-core/src/tools/backend/storage.ts index 0308496cc8bc1..a6250131a25dc 100644 --- a/packages/playwright-core/src/tools/backend/storage.ts +++ b/packages/playwright-core/src/tools/backend/storage.ts @@ -35,7 +35,7 @@ const storageState = defineTool({ const browserContext = await context.ensureBrowserContext(); const state = await browserContext.storageState(); const serializedState = JSON.stringify(state, null, 2); - const resolvedFile = await response.resolveClientFile({ prefix: 'storage-state', ext: 'json', suggestedFilename: params.filename }, 'Storage state'); + const resolvedFile = await response.resolveClientOutputFile({ prefix: 'storage-state', ext: 'json', suggestedFilename: params.filename }, 'Storage state'); response.addCode(`await page.context().storageState({ path: ${escapeWithQuotes(resolvedFile.relativeName)} });`); await response.addFileResult(resolvedFile, serializedState); }, diff --git a/packages/playwright-core/src/tools/backend/video.ts b/packages/playwright-core/src/tools/backend/video.ts index ad49b09bc2d7e..f3ab617dcff39 100644 --- a/packages/playwright-core/src/tools/backend/video.ts +++ b/packages/playwright-core/src/tools/backend/video.ts @@ -35,7 +35,7 @@ const videoStart = defineTool({ }, handle: async (context, params, response) => { - const resolvedFile = await response.resolveClientFile({ prefix: 'video', ext: 'webm', suggestedFilename: params.filename }, 'Video'); + const resolvedFile = await response.resolveClientOutputFile({ prefix: 'video', ext: 'webm', suggestedFilename: params.filename }, 'Video'); await context.startVideoRecording(resolvedFile.fileName, { size: params.size }); response.addTextResult('Video recording started.'); }, @@ -59,7 +59,7 @@ const videoStop = defineTool({ return; } for (const fileName of fileNames) { - const resolvedFile = await response.resolveClientFile({ + const resolvedFile = await response.resolveClientOutputFile({ prefix: 'video', ext: 'webm', suggestedFilename: fileName diff --git a/tests/mcp/files.spec.ts b/tests/mcp/files.spec.ts index 3ed6693140ff1..4456529cac0ec 100644 --- a/tests/mcp/files.spec.ts +++ b/tests/mcp/files.spec.ts @@ -290,6 +290,46 @@ test('file upload is restricted to cwd if no roots are configured', async ({ sta }); }); +test('file upload resolves relative paths against the root', async ({ startClient, server }, testInfo) => { + const rootDir = testInfo.outputPath('workspace'); + await fs.mkdir(rootDir, { recursive: true }); + await fs.writeFile(path.join(rootDir, 'inside.txt'), 'Inside root'); + + const { client } = await startClient({ + roots: [ + { + name: 'workspace', + uri: `file://${rootDir}`, + } + ], + }); + + server.setContent('/', ``, 'text/html'); + + await client.callTool({ + name: 'browser_navigate', + arguments: { url: server.PREFIX }, + }); + + await client.callTool({ + name: 'browser_click', + arguments: { + element: 'Textbox', + target: 'e2', + }, + }); + + // The file lives in the root, not in the server's cwd. + expect(await client.callTool({ + name: 'browser_file_upload', + arguments: { + paths: ['inside.txt'], + }, + })).toHaveResponse({ + code: expect.stringContaining(JSON.stringify(path.join(rootDir, 'inside.txt'))), + }); +}); + test('file upload unrestricted when flag is set', async ({ startClient, server }, testInfo) => { const rootDir = testInfo.outputPath('workspace'); await fs.mkdir(rootDir, { recursive: true }); diff --git a/tests/mcp/screenshot.spec.ts b/tests/mcp/screenshot.spec.ts index 09775eb24cc48..b9f6ad7d2e69f 100644 --- a/tests/mcp/screenshot.spec.ts +++ b/tests/mcp/screenshot.spec.ts @@ -246,6 +246,31 @@ test('browser_take_screenshot (filename: "output.png")', async ({ client, server expect(files[0]).toMatch(/^output\.png$/); }); +test('browser_take_screenshot (filename: "sub/dir/output.png")', async ({ client, server }, testInfo) => { + expect(await client.callTool({ + name: 'browser_navigate', + arguments: { url: server.HELLO_WORLD }, + })).toHaveResponse({ + code: expect.stringContaining(`page.goto('http://localhost`), + }); + + expect(await client.callTool({ + name: 'browser_take_screenshot', + arguments: { + filename: 'sub/dir/output.png', + }, + })).toEqual({ + content: [ + { + text: expect.stringContaining(`output.png`), + type: 'text', + }, + ], + }); + + expect(fs.existsSync(testInfo.outputPath('sub', 'dir', 'output.png'))).toBeTruthy(); +}); + test('browser_take_screenshot (imageResponses=omit)', async ({ startClient, server }, testInfo) => { const outputDir = testInfo.outputPath('output'); const { client } = await startClient({