diff --git a/src/config.ts b/src/config.ts index 590c9df..501c434 100644 --- a/src/config.ts +++ b/src/config.ts @@ -20,10 +20,9 @@ import os from 'os'; import path from 'path'; import { devices } from 'playwright'; -import { sanitizeForFilePath } from './tools/utils.js'; - import type { Config, ToolCapability } from '../config.js'; import type { BrowserContextOptions, LaunchOptions } from 'playwright'; +import { sanitizeForFilePath } from './tools/utils.js'; export type CLIOptions = { browser?: string; diff --git a/src/context.ts b/src/context.ts index 0dd42d3..3847d99 100644 --- a/src/context.ts +++ b/src/context.ts @@ -23,6 +23,7 @@ import { Tab } from './tab.js'; import type { ImageContent, TextContent } from '@modelcontextprotocol/sdk/types.js'; import type { ModalState, Tool, ToolActionResult } from './tools/tool.js'; import type { Config } from '../config.js'; +import { outputFile } from './config.js'; type PendingAction = { dialogShown: ManualPromise; @@ -38,6 +39,7 @@ export class Context { private _currentTab: Tab | undefined; private _modalStates: (ModalState & { tab: Tab })[] = []; private _pendingAction: PendingAction | undefined; + private _downloads: { download: playwright.Download, finished: boolean, outputFile: string }[] = []; constructor(tools: Tool[], config: Config) { this.tools = tools; @@ -164,6 +166,17 @@ ${code.join('\n')} }; } + if (this._downloads.length) { + result.push('', '### Downloads'); + for (const entry of this._downloads) { + if (entry.finished) + result.push(`- Downloaded file ${entry.download.suggestedFilename()} to ${entry.outputFile}`); + else + result.push(`- Downloading file ${entry.download.suggestedFilename()} ...`); + } + result.push(''); + } + if (this.tabs().length > 1) result.push(await this.listTabsMarkdown(), ''); @@ -228,6 +241,17 @@ ${code.join('\n')} this._pendingAction?.dialogShown.resolve(); } + async downloadStarted(tab: Tab, download: playwright.Download) { + const entry = { + download, + finished: false, + outputFile: await outputFile(this.config, download.suggestedFilename()) + }; + this._downloads.push(entry); + await download.saveAs(entry.outputFile); + entry.finished = true; + } + private _onPageCreated(page: playwright.Page) { const tab = new Tab(this, page, tab => this._onPageClosed(tab)); this._tabs.push(tab); diff --git a/src/tab.ts b/src/tab.ts index 69c27c2..177c345 100644 --- a/src/tab.ts +++ b/src/tab.ts @@ -48,6 +48,9 @@ export class Tab { }, this); }); page.on('dialog', dialog => this.context.dialogShown(this, dialog)); + page.on('download', download => { + void this.context.downloadStarted(this, download); + }); page.setDefaultNavigationTimeout(60000); page.setDefaultTimeout(5000); } diff --git a/src/tools/pdf.ts b/src/tools/pdf.ts index 122ea88..79dca64 100644 --- a/src/tools/pdf.ts +++ b/src/tools/pdf.ts @@ -31,7 +31,7 @@ const pdf = defineTool({ handle: async context => { const tab = context.currentTabOrDie(); - const fileName = await outputFile(context.config, `page-${new Date().toISOString()}'.pdf'`); + const fileName = await outputFile(context.config, `page-${new Date().toISOString()}.pdf`); const code = [ `// Save page as ${fileName}`, diff --git a/tests/files.spec.ts b/tests/files.spec.ts index 2672307..10f6b8c 100644 --- a/tests/files.spec.ts +++ b/tests/files.spec.ts @@ -16,6 +16,7 @@ import { test, expect } from './fixtures.js'; import fs from 'fs/promises'; +import path from 'path'; test('browser_file_upload', async ({ client }) => { expect(await client.callTool({ @@ -96,3 +97,27 @@ The tool "browser_file_upload" can only be used when there is related modal stat - [File chooser]: can be handled by the "browser_file_upload" tool`); } }); + +test('clicking on download link emits download', async ({ startClient }, testInfo) => { + const outputDir = testInfo.outputPath('output'); + const client = await startClient({ + config: { outputDir }, + }); + + expect(await client.callTool({ + name: 'browser_navigate', + arguments: { + url: 'data:text/html,Download', + }, + })).toContainTextContent('- link "Download" [ref=s1e3]'); + + await expect.poll(() => client.callTool({ + name: 'browser_click', + arguments: { + element: 'Download link', + ref: 's1e3', + }, + })).toContainTextContent(` +### Downloads +- Downloaded file test.txt to ${path.join(outputDir, 'test-txt')}`); +});