Skip to content

feat: browser_file_download #209

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 11 commits into
base: main
Choose a base branch
from
Open
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
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,13 @@ http.createServer(async (req, res) => {

<!-- NOTE: This has been generated via update-readme.js -->

- **browser_file_download**
- Description: Accept file downloads. Only use this if there is a download modal visible.
- Parameters:
- `filenames` (array): The filenames to accept. All other files will be canceled.

<!-- NOTE: This has been generated via update-readme.js -->

- **browser_pdf_save**
- Description: Save page as PDF
- Parameters: None
Expand Down
19 changes: 18 additions & 1 deletion src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@ export class Context {
this._modalStates = this._modalStates.filter(state => state !== modalState);
}

hasModalState(type: ModalState['type']) {
return this._modalStates.some(state => state.type === type);
}

modalStatesMarkdown(): string[] {
const result: string[] = ['### Modal state'];
for (const state of this._modalStates) {
Expand Down Expand Up @@ -340,6 +344,13 @@ export class Tab {
fileChooser: chooser,
}, this);
});
page.on('download', download => {
this.context.setModalState({
type: 'download',
description: `Download (${download.suggestedFilename()})`,
download,
}, this);
});
page.on('dialog', dialog => this.context.dialogShown(this, dialog));
page.setDefaultNavigationTimeout(60000);
page.setDefaultTimeout(5000);
Expand All @@ -356,7 +367,13 @@ export class Tab {
}

async navigate(url: string) {
await this.page.goto(url, { waitUntil: 'domcontentloaded' });
try {
await this.page.goto(url, { waitUntil: 'domcontentloaded' });
} catch (error) {
if (error instanceof Error && error.message.includes('net::ERR_ABORTED') && this.context.hasModalState('download'))
return;
}

// Cap load event to 5 seconds, the page is operational at this point.
await this.page.waitForLoadState('load', { timeout: 5000 }).catch(() => {});
}
Expand Down
60 changes: 58 additions & 2 deletions src/tools/files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,13 @@
*/

import { z } from 'zod';
import { defineTool, type ToolFactory } from './tool';
import os from 'os';
import path from 'path';

const uploadFile: ToolFactory = captureSnapshot => defineTool({
import { defineTool, DownloadModalState, type ToolFactory } from './tool';
import { sanitizeForFilePath } from './utils';

const uploadFile: ToolFactory = captureSnapshot => ({
capability: 'files',

schema: {
Expand Down Expand Up @@ -52,6 +56,58 @@ const uploadFile: ToolFactory = captureSnapshot => defineTool({
clearsModalState: 'fileChooser',
});

const downloadFile = defineTool({
capability: 'files',

schema: {
name: 'browser_file_download',
description: 'Accept file downloads. Only use this if there is a download modal visible.',
inputSchema: z.object({
filenames: z.array(z.string()).describe('The filenames to accept. All other files will be canceled.'),
}),
},

handle: async (context, params) => {
const modals = context.modalStates().filter(state => state.type === 'download');
if (!modals.length)
throw new Error('No download modal visible');

const accepted = new Set<DownloadModalState>();
for (const filename of params.filenames) {
const download = modals.find(modal => modal.download.suggestedFilename() === filename);
if (!download)
throw new Error(`No download modal visible for file ${filename}`);
accepted.add(download);
}

return {
code: [`// <internal code to accept and cancel files>`],
action: async () => {
const text: string[] = [];
await Promise.all(modals.map(async modal => {
context.clearModalState(modal);

if (!accepted.has(modal))
return modal.download.cancel();

const filePath = path.join(os.tmpdir(), sanitizeForFilePath(`download-${new Date().toISOString()}`), modal.download.suggestedFilename());
try {
await modal.download.saveAs(filePath);
text.push(`Downloaded ${modal.download.suggestedFilename()} to ${filePath}`);
} catch {
text.push(`Failed to download ${modal.download.suggestedFilename()}`);
}
}));
return { content: [{ type: 'text', text: text.join('\n') }] };
},
captureSnapshot: false,
waitForNetwork: true,
};
},
clearsModalState: 'download',
});

export default (captureSnapshot: boolean) => [
uploadFile(captureSnapshot),
downloadFile,
];
8 changes: 7 additions & 1 deletion src/tools/tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,19 @@ export type FileUploadModalState = {
fileChooser: playwright.FileChooser;
};

export type DownloadModalState = {
type: 'download';
description: string;
download: playwright.Download;
};

export type DialogModalState = {
type: 'dialog';
description: string;
dialog: playwright.Dialog;
};

export type ModalState = FileUploadModalState | DialogModalState;
export type ModalState = FileUploadModalState | DialogModalState | DownloadModalState;

export type ToolActionResult = { content?: (ImageContent | TextContent)[] } | undefined | void;

Expand Down
2 changes: 2 additions & 0 deletions tests/capabilities.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ test('test snapshot tool list', async ({ client }) => {
'browser_click',
'browser_console_messages',
'browser_drag',
'browser_file_download',
'browser_file_upload',
'browser_handle_dialog',
'browser_hover',
Expand Down Expand Up @@ -51,6 +52,7 @@ test('test vision tool list', async ({ visionClient }) => {
expect(new Set(visionTools.map(t => t.name))).toEqual(new Set([
'browser_close',
'browser_console_messages',
'browser_file_download',
'browser_file_upload',
'browser_handle_dialog',
'browser_install',
Expand Down
87 changes: 87 additions & 0 deletions tests/files.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,3 +85,90 @@ test('browser_file_upload', async ({ client }) => {
- [File chooser]: can be handled by the "browser_file_upload" tool`);
}
});

test.describe('browser_file_download', () => {
test('after clicking on download link', async ({ client }) => {
expect(await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<a href="data:text/plain,Hello world!" download="test.txt">Download</a>',
},
})).toContainTextContent('- link "Download" [ref=s1e3]');

expect(await client.callTool({
name: 'browser_click',
arguments: {
element: 'Download link',
ref: 's1e3',
},
})).toContainTextContent(`
### Modal state
- [Download (test.txt)]: can be handled by the "browser_file_download" tool`);

expect(await client.callTool({
name: 'browser_snapshot',
arguments: {},
})).toContainTextContent(`
Tool "browser_snapshot" does not handle the modal state.
### Modal state
- [Download (test.txt)]: can be handled by the "browser_file_download" tool`.trim());

expect(await client.callTool({
name: 'browser_file_download',
arguments: {
filenames: ['wrong_file.txt'],
},
})).toContainTextContent(`Error: No download modal visible for file wrong_file.txt`);

expect(await client.callTool({
name: 'browser_file_download',
arguments: {
filenames: ['test.txt'],
},
})).toContainTextContent([`Downloaded test.txt to`, '// <internal code to accept and cancel files>']);
});

test('navigating to downloading link', async ({ client, server, mcpBrowser }) => {
test.skip(mcpBrowser === 'msedge', 'msedge behaves differently');

server.route('/', (req, res) => {
res.setHeader('Content-Disposition', 'attachment; filename="test.txt"');
res.end('Hello world!');
});
expect(await client.callTool({
name: 'browser_navigate',
arguments: {
url: server.PREFIX,
},
})).toContainTextContent('### Modal state\n- [Download (test.txt)]');
expect(await client.callTool({
name: 'browser_file_download',
arguments: {
filenames: ['test.txt'],
},
})).toContainTextContent([`Downloaded test.txt to`, '// <internal code to accept and cancel files>\n```\n\n- Page URL: about:blank']);
});

test('navigating to PDF link', async ({ client, server, mcpBrowser }) => {
test.skip(mcpBrowser === 'msedge', 'msedge behaves differently');
test.skip(mcpBrowser === 'webkit', 'webkit behaves differently');

server.route('/', (req, res) => {
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', 'filename="test.pdf"');
res.end('Hello world!');
});
expect(await client.callTool({
name: 'browser_navigate',
arguments: {
url: server.PREFIX,
},
})).toContainTextContent('### Modal state\n- [Download (test.pdf)]');
expect(await client.callTool({
name: 'browser_file_download',
arguments: {
filenames: ['test.pdf'],
},
})).toContainTextContent([`Downloaded test.pdf to`, '// <internal code to accept and cancel files>\n```\n\n- Page URL: about:blank']);
});
});
Loading