438 lines
12 KiB
TypeScript
438 lines
12 KiB
TypeScript
|
|
/**
|
||
|
|
* 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/promises';
|
||
|
|
import path from 'path';
|
||
|
|
|
||
|
|
import { test, expect, parseResponse } from './fixtures';
|
||
|
|
|
||
|
|
test('browser_file_upload', async ({ client, server }, testInfo) => {
|
||
|
|
server.setContent('/', `
|
||
|
|
<input type="file" />
|
||
|
|
<button>Button</button>
|
||
|
|
`, 'text/html');
|
||
|
|
|
||
|
|
expect(await client.callTool({
|
||
|
|
name: 'browser_navigate',
|
||
|
|
arguments: { url: server.PREFIX },
|
||
|
|
})).toHaveResponse({
|
||
|
|
snapshot: expect.stringContaining(`- generic [active] [ref=e1]:
|
||
|
|
- button "Choose File" [ref=e2]
|
||
|
|
- button "Button" [ref=e3]`),
|
||
|
|
});
|
||
|
|
|
||
|
|
{
|
||
|
|
expect(await client.callTool({
|
||
|
|
name: 'browser_file_upload',
|
||
|
|
arguments: { paths: [] },
|
||
|
|
})).toHaveResponse({
|
||
|
|
isError: true,
|
||
|
|
error: expect.stringContaining(`The tool "browser_file_upload" can only be used when there is related modal state present.`),
|
||
|
|
modalState: undefined,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
expect(await client.callTool({
|
||
|
|
name: 'browser_click',
|
||
|
|
arguments: {
|
||
|
|
element: 'Textbox',
|
||
|
|
target: 'e2',
|
||
|
|
},
|
||
|
|
})).toHaveResponse({
|
||
|
|
modalState: expect.stringContaining(`- [File chooser]: can be handled by browser_file_upload`),
|
||
|
|
});
|
||
|
|
|
||
|
|
const filePath = testInfo.outputPath('test.txt');
|
||
|
|
await fs.writeFile(filePath, 'Hello, world!');
|
||
|
|
|
||
|
|
{
|
||
|
|
const response = await client.callTool({
|
||
|
|
name: 'browser_file_upload',
|
||
|
|
arguments: {
|
||
|
|
paths: [filePath],
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(response).toHaveResponse({
|
||
|
|
code: expect.stringContaining(`await fileChooser.setFiles(`),
|
||
|
|
modalState: undefined,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
{
|
||
|
|
const response = await client.callTool({
|
||
|
|
name: 'browser_click',
|
||
|
|
arguments: {
|
||
|
|
element: 'Textbox',
|
||
|
|
target: 'e2',
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(response).toHaveResponse({
|
||
|
|
modalState: `- [File chooser]: can be handled by browser_file_upload`,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
{
|
||
|
|
const response = await client.callTool({
|
||
|
|
name: 'browser_click',
|
||
|
|
arguments: {
|
||
|
|
element: 'Button',
|
||
|
|
target: 'e3',
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(response).toHaveResponse({
|
||
|
|
isError: true,
|
||
|
|
error: `Error: Tool "browser_click" does not handle the modal state.`,
|
||
|
|
modalState: expect.stringContaining(`- [File chooser]: can be handled by browser_file_upload`),
|
||
|
|
});
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
test('clicking on download link emits download', async ({ startClient, server }, testInfo) => {
|
||
|
|
const { client } = await startClient({
|
||
|
|
config: { outputDir: testInfo.outputPath('output') },
|
||
|
|
});
|
||
|
|
|
||
|
|
server.setContent('/', `<a href="/download" download="test.txt">Download</a>`, 'text/html');
|
||
|
|
server.setContent('/download', 'Data', 'text/plain');
|
||
|
|
|
||
|
|
expect(await client.callTool({
|
||
|
|
name: 'browser_navigate',
|
||
|
|
arguments: { url: server.PREFIX },
|
||
|
|
})).toHaveResponse({
|
||
|
|
snapshot: expect.stringContaining(`- link "Download" [ref=e2]`),
|
||
|
|
});
|
||
|
|
|
||
|
|
const response = await client.callTool({
|
||
|
|
name: 'browser_click',
|
||
|
|
arguments: {
|
||
|
|
element: 'Download link',
|
||
|
|
target: 'e2',
|
||
|
|
},
|
||
|
|
});
|
||
|
|
const parsed = parseResponse(response);
|
||
|
|
let events = parsed.events;
|
||
|
|
await expect.poll(async () => {
|
||
|
|
const r = await client.callTool({ name: 'browser_snapshot' });
|
||
|
|
const p = parseResponse(r);
|
||
|
|
if (p.events)
|
||
|
|
events += '\n' + p.events;
|
||
|
|
return events;
|
||
|
|
}).toContain(`- Downloading file test.txt ...
|
||
|
|
- Downloaded file test.txt to "output${path.sep}test.txt"`);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('navigating to download link emits download', async ({ startClient, server, mcpBrowser }, testInfo) => {
|
||
|
|
test.skip(mcpBrowser !== 'chromium', 'This test is racy');
|
||
|
|
const { client } = await startClient({
|
||
|
|
config: { outputDir: testInfo.outputPath('output') },
|
||
|
|
});
|
||
|
|
|
||
|
|
server.setRoute('/download', (req, res) => {
|
||
|
|
res.writeHead(200, {
|
||
|
|
'Content-Type': 'text/plain',
|
||
|
|
'Content-Disposition': 'attachment; filename=test.txt',
|
||
|
|
});
|
||
|
|
res.end('Hello world!');
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(await client.callTool({
|
||
|
|
name: 'browser_navigate',
|
||
|
|
arguments: {
|
||
|
|
url: server.PREFIX + '/download',
|
||
|
|
},
|
||
|
|
})).toHaveResponse({
|
||
|
|
events: expect.stringMatching(`- Downloaded file test\.txt to|- Downloading file test\.txt`),
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
test('file upload restricted to roots by default', async ({ startClient, server }, testInfo) => {
|
||
|
|
const rootDir = testInfo.outputPath('workspace');
|
||
|
|
await fs.mkdir(rootDir, { recursive: true });
|
||
|
|
|
||
|
|
const { client } = await startClient({
|
||
|
|
roots: [
|
||
|
|
{
|
||
|
|
name: 'workspace',
|
||
|
|
uri: `file://${rootDir}`,
|
||
|
|
}
|
||
|
|
],
|
||
|
|
});
|
||
|
|
|
||
|
|
server.setContent('/', `<input type="file" />`, 'text/html');
|
||
|
|
|
||
|
|
await client.callTool({
|
||
|
|
name: 'browser_navigate',
|
||
|
|
arguments: { url: server.PREFIX },
|
||
|
|
});
|
||
|
|
|
||
|
|
// Click on file input to trigger file chooser
|
||
|
|
await client.callTool({
|
||
|
|
name: 'browser_click',
|
||
|
|
arguments: {
|
||
|
|
element: 'Textbox',
|
||
|
|
target: 'e2',
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
// Create a file inside the root
|
||
|
|
const fileInsideRoot = testInfo.outputPath('workspace', 'inside.txt');
|
||
|
|
await fs.writeFile(fileInsideRoot, 'Inside root');
|
||
|
|
|
||
|
|
// Should succeed - file is inside root
|
||
|
|
expect(await client.callTool({
|
||
|
|
name: 'browser_file_upload',
|
||
|
|
arguments: {
|
||
|
|
paths: [fileInsideRoot],
|
||
|
|
},
|
||
|
|
})).toHaveResponse({
|
||
|
|
code: expect.stringContaining(`await fileChooser.setFiles(`),
|
||
|
|
});
|
||
|
|
|
||
|
|
// Click again to open file chooser
|
||
|
|
await client.callTool({
|
||
|
|
name: 'browser_click',
|
||
|
|
arguments: {
|
||
|
|
element: 'Textbox',
|
||
|
|
target: 'e2',
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
// Create a file outside the root
|
||
|
|
const fileOutsideRoot = testInfo.outputPath('outside.txt');
|
||
|
|
await fs.writeFile(fileOutsideRoot, 'Outside root');
|
||
|
|
|
||
|
|
// Should fail - file is outside root
|
||
|
|
expect(await client.callTool({
|
||
|
|
name: 'browser_file_upload',
|
||
|
|
arguments: {
|
||
|
|
paths: [fileOutsideRoot],
|
||
|
|
},
|
||
|
|
})).toHaveResponse({
|
||
|
|
isError: true,
|
||
|
|
error: expect.stringMatching('File access denied: .* is outside allowed roots'),
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
test('file upload is restricted to cwd if no roots are configured', async ({ startClient, server }, testInfo) => {
|
||
|
|
const rootDir = testInfo.outputPath('workspace');
|
||
|
|
await fs.mkdir(rootDir, { recursive: true });
|
||
|
|
|
||
|
|
const { client } = await startClient({
|
||
|
|
cwd: rootDir,
|
||
|
|
});
|
||
|
|
|
||
|
|
server.setContent('/', `<input type="file" />`, 'text/html');
|
||
|
|
|
||
|
|
await client.callTool({
|
||
|
|
name: 'browser_navigate',
|
||
|
|
arguments: { url: server.PREFIX },
|
||
|
|
});
|
||
|
|
|
||
|
|
// Click on file input to trigger file chooser
|
||
|
|
await client.callTool({
|
||
|
|
name: 'browser_click',
|
||
|
|
arguments: {
|
||
|
|
element: 'Textbox',
|
||
|
|
target: 'e2',
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
// Create a file inside the root
|
||
|
|
const fileInsideRoot = testInfo.outputPath('workspace', 'inside.txt');
|
||
|
|
await fs.writeFile(fileInsideRoot, 'Inside root');
|
||
|
|
|
||
|
|
// Should succeed - file is inside root
|
||
|
|
expect(await client.callTool({
|
||
|
|
name: 'browser_file_upload',
|
||
|
|
arguments: {
|
||
|
|
paths: [fileInsideRoot],
|
||
|
|
},
|
||
|
|
})).toHaveResponse({
|
||
|
|
code: expect.stringContaining(`await fileChooser.setFiles(`),
|
||
|
|
});
|
||
|
|
|
||
|
|
// Click again to open file chooser
|
||
|
|
await client.callTool({
|
||
|
|
name: 'browser_click',
|
||
|
|
arguments: {
|
||
|
|
element: 'Textbox',
|
||
|
|
target: 'e2',
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
const fileOutsideRoot = testInfo.outputPath('outside.txt');
|
||
|
|
await fs.writeFile(fileOutsideRoot, 'Outside root');
|
||
|
|
|
||
|
|
expect(await client.callTool({
|
||
|
|
name: 'browser_file_upload',
|
||
|
|
arguments: {
|
||
|
|
paths: [fileOutsideRoot],
|
||
|
|
},
|
||
|
|
})).toHaveResponse({
|
||
|
|
isError: true,
|
||
|
|
error: expect.stringMatching('File access denied: .* is outside allowed roots. Allowed roots: ' + rootDir.replace(/\\/g, '\\\\')),
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
test('file upload unrestricted when flag is set', async ({ startClient, server }, testInfo) => {
|
||
|
|
const rootDir = testInfo.outputPath('workspace');
|
||
|
|
await fs.mkdir(rootDir, { recursive: true });
|
||
|
|
|
||
|
|
const { client } = await startClient({
|
||
|
|
config: {
|
||
|
|
allowUnrestrictedFileAccess: true,
|
||
|
|
},
|
||
|
|
roots: [
|
||
|
|
{
|
||
|
|
name: 'workspace',
|
||
|
|
uri: `file://${rootDir}`,
|
||
|
|
}
|
||
|
|
],
|
||
|
|
});
|
||
|
|
|
||
|
|
server.setContent('/', `<input type="file" />`, 'text/html');
|
||
|
|
|
||
|
|
await client.callTool({
|
||
|
|
name: 'browser_navigate',
|
||
|
|
arguments: { url: server.PREFIX },
|
||
|
|
});
|
||
|
|
|
||
|
|
// Click on file input to trigger file chooser
|
||
|
|
await client.callTool({
|
||
|
|
name: 'browser_click',
|
||
|
|
arguments: {
|
||
|
|
element: 'Textbox',
|
||
|
|
target: 'e2',
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
// Create a file outside the root
|
||
|
|
const fileOutsideRoot = testInfo.outputPath('outside.txt');
|
||
|
|
await fs.writeFile(fileOutsideRoot, 'Outside root');
|
||
|
|
|
||
|
|
// Should succeed - unrestricted uploads are allowed
|
||
|
|
expect(await client.callTool({
|
||
|
|
name: 'browser_file_upload',
|
||
|
|
arguments: {
|
||
|
|
paths: [fileOutsideRoot],
|
||
|
|
},
|
||
|
|
})).toHaveResponse({
|
||
|
|
code: expect.stringContaining(`await fileChooser.setFiles(`),
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
const dropzoneHtml = `
|
||
|
|
<div id="dropzone" aria-label="dropzone" style="width:300px;height:200px;border:2px dashed #888"></div>
|
||
|
|
<script>
|
||
|
|
window.__dropInfo = null;
|
||
|
|
const zone = document.getElementById('dropzone');
|
||
|
|
zone.addEventListener('dragenter', e => e.preventDefault());
|
||
|
|
zone.addEventListener('dragover', e => e.preventDefault());
|
||
|
|
zone.addEventListener('drop', async e => {
|
||
|
|
e.preventDefault();
|
||
|
|
const files = [];
|
||
|
|
for (const f of e.dataTransfer.files)
|
||
|
|
files.push({ name: f.name, size: f.size, text: await f.text() });
|
||
|
|
const data = {};
|
||
|
|
for (const t of e.dataTransfer.types) {
|
||
|
|
if (t !== 'Files')
|
||
|
|
data[t] = e.dataTransfer.getData(t);
|
||
|
|
}
|
||
|
|
window.__dropInfo = { files, data };
|
||
|
|
});
|
||
|
|
</script>
|
||
|
|
`;
|
||
|
|
|
||
|
|
test('browser_drop files', async ({ client, server }, testInfo) => {
|
||
|
|
server.setContent('/', dropzoneHtml, 'text/html');
|
||
|
|
|
||
|
|
await client.callTool({
|
||
|
|
name: 'browser_navigate',
|
||
|
|
arguments: { url: server.PREFIX },
|
||
|
|
});
|
||
|
|
|
||
|
|
const filePath = testInfo.outputPath('drop-me.txt');
|
||
|
|
await fs.writeFile(filePath, 'hello');
|
||
|
|
|
||
|
|
expect(await client.callTool({
|
||
|
|
name: 'browser_drop',
|
||
|
|
arguments: {
|
||
|
|
element: 'dropzone',
|
||
|
|
target: 'e2',
|
||
|
|
paths: [filePath],
|
||
|
|
},
|
||
|
|
})).toHaveResponse({
|
||
|
|
code: expect.stringContaining(`.drop(`),
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(await client.callTool({
|
||
|
|
name: 'browser_evaluate',
|
||
|
|
arguments: { function: '() => window.__dropInfo' },
|
||
|
|
})).toHaveResponse({
|
||
|
|
result: expect.stringContaining(`"text": "hello"`),
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
test('browser_drop data', async ({ client, server }) => {
|
||
|
|
server.setContent('/', dropzoneHtml, 'text/html');
|
||
|
|
|
||
|
|
await client.callTool({
|
||
|
|
name: 'browser_navigate',
|
||
|
|
arguments: { url: server.PREFIX },
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(await client.callTool({
|
||
|
|
name: 'browser_drop',
|
||
|
|
arguments: {
|
||
|
|
element: 'dropzone',
|
||
|
|
target: 'e2',
|
||
|
|
data: { 'text/plain': 'hello world' },
|
||
|
|
},
|
||
|
|
})).toHaveResponse({
|
||
|
|
code: expect.stringContaining(`.drop(`),
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(await client.callTool({
|
||
|
|
name: 'browser_evaluate',
|
||
|
|
arguments: { function: '() => window.__dropInfo.data["text/plain"]' },
|
||
|
|
})).toHaveResponse({
|
||
|
|
result: `"hello world"`,
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
test('browser_drop requires paths or data', async ({ client, server }) => {
|
||
|
|
server.setContent('/', dropzoneHtml, 'text/html');
|
||
|
|
|
||
|
|
await client.callTool({
|
||
|
|
name: 'browser_navigate',
|
||
|
|
arguments: { url: server.PREFIX },
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(await client.callTool({
|
||
|
|
name: 'browser_drop',
|
||
|
|
arguments: {
|
||
|
|
element: 'dropzone',
|
||
|
|
target: 'e2',
|
||
|
|
},
|
||
|
|
})).toHaveResponse({
|
||
|
|
isError: true,
|
||
|
|
error: expect.stringContaining(`At least one of "paths" or "data" must be provided.`),
|
||
|
|
});
|
||
|
|
});
|