mirror of
https://github.com/ChromeDevTools/chrome-devtools-mcp.git
synced 2026-09-14 19:45:30 +08:00
feat: support uploading multiple files with upload_file (#2257)
Closes #1217. `upload_file` only took a single path, so there was no way to fill an `input[type=file][multiple]` in one call. `filePath` now also accepts an array of paths. A plain string still works for the single-file case, so existing callers don't change — Puppeteer's `uploadFile`/`fileChooser.accept` already take multiple paths, so the handler just normalizes to an array and forwards them. The union (`string | string[]`) needed a bit of plumbing: the CLI generator threw on non-scalar types, the docs generator rendered it as `unknown`, and the telemetry transformer didn't know `ZodUnion`. The CLI exposes the single-value form (it can't pass arrays anyway), the docs show `string or array`, and metrics count it like an array with scalars normalized to one. Regenerated artifacts are included. Added a test that uploads two files to a multi-file input and asserts both land, plus telemetry coverage for the union. `npm run typecheck`, lint/prettier, and the upload + transformation tests all pass. --------- Co-authored-by: Nikolay Vitkov <34244704+Lightning00Blade@users.noreply.github.com>
This commit is contained in:
@@ -169,7 +169,7 @@
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- **filePath** (string) **(required)**: The local path of the file to upload
|
||||
- **filePaths** (array) **(required)**: One or more local paths of files to upload.
|
||||
- **uid** (string) **(required)**: The uid of the file input element or an element that will open file chooser on the page from the page content snapshot
|
||||
- **includeSnapshot** (boolean) _(optional)_: Whether to include a snapshot in the response. Default is false.
|
||||
|
||||
|
||||
+4
-1
@@ -234,7 +234,10 @@ export class ToolHandler {
|
||||
if (this.tool.verifyFilesSchema) {
|
||||
for (const key of this.tool.verifyFilesSchema) {
|
||||
const filePath = params[key];
|
||||
await context.validatePath(filePath as string);
|
||||
const paths = Array.isArray(filePath) ? filePath : [filePath];
|
||||
for (const path of paths) {
|
||||
await context.validatePath(path as string);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (isPageScopedTool(this.tool)) {
|
||||
|
||||
@@ -1287,10 +1287,10 @@ export const commands: Commands = {
|
||||
'The uid of the file input element or an element that will open file chooser on the page from the page content snapshot',
|
||||
required: true,
|
||||
},
|
||||
filePath: {
|
||||
name: 'filePath',
|
||||
type: 'string',
|
||||
description: 'The local path of the file to upload',
|
||||
filePaths: {
|
||||
name: 'filePaths',
|
||||
type: 'array',
|
||||
description: 'One or more local paths of files to upload.',
|
||||
required: true,
|
||||
},
|
||||
includeSnapshot: {
|
||||
|
||||
@@ -551,11 +551,16 @@
|
||||
"args": [
|
||||
{
|
||||
"name": "file_path_length",
|
||||
"argType": "number"
|
||||
"argType": "number",
|
||||
"isDeprecated": true
|
||||
},
|
||||
{
|
||||
"name": "include_snapshot",
|
||||
"argType": "boolean"
|
||||
},
|
||||
{
|
||||
"name": "file_paths_count",
|
||||
"argType": "number"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
+9
-6
@@ -443,19 +443,22 @@ export const uploadFile = definePageTool({
|
||||
.describe(
|
||||
'The uid of the file input element or an element that will open file chooser on the page from the page content snapshot',
|
||||
),
|
||||
filePath: zod.string().describe('The local path of the file to upload'),
|
||||
filePaths: zod
|
||||
.array(zod.string())
|
||||
.min(1)
|
||||
.describe('One or more local paths of files to upload.'),
|
||||
includeSnapshot: includeSnapshotSchema,
|
||||
},
|
||||
blockedByDialog: true,
|
||||
verifyFilesSchema: ['filePath'],
|
||||
verifyFilesSchema: ['filePaths'],
|
||||
handler: async (request, response) => {
|
||||
const {uid, filePath} = request.params;
|
||||
const {uid, filePaths} = request.params;
|
||||
using handle = (await request.page.getElementByUid(
|
||||
uid,
|
||||
)) as ElementHandle<HTMLInputElement>;
|
||||
|
||||
try {
|
||||
await handle.uploadFile(filePath);
|
||||
await handle.uploadFile(...filePaths);
|
||||
} catch {
|
||||
// Some sites use a proxy element to trigger file upload instead of
|
||||
// a type=file element. In this case, we want to default to
|
||||
@@ -465,7 +468,7 @@ export const uploadFile = definePageTool({
|
||||
request.page.pptrPage.waitForFileChooser({timeout: 3000}),
|
||||
handle.asLocator().click(),
|
||||
]);
|
||||
await fileChooser.accept([filePath]);
|
||||
await fileChooser.accept(filePaths);
|
||||
} catch {
|
||||
throw new Error(
|
||||
`Failed to upload file. The element could not accept the file directly, and clicking it did not trigger a file chooser.`,
|
||||
@@ -475,7 +478,7 @@ export const uploadFile = definePageTool({
|
||||
if (request.params.includeSnapshot) {
|
||||
response.includeSnapshot();
|
||||
}
|
||||
response.appendResponseLine(`File uploaded from ${filePath}.`);
|
||||
response.appendResponseLine(`File uploaded from ${filePaths.join(', ')}.`);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1231,7 +1231,7 @@ describe('input', () => {
|
||||
{
|
||||
params: {
|
||||
uid: '1_2',
|
||||
filePath: testFilePath,
|
||||
filePaths: [testFilePath],
|
||||
},
|
||||
page: context.getSelectedMcpPage(),
|
||||
},
|
||||
@@ -1248,6 +1248,52 @@ describe('input', () => {
|
||||
await fs.unlink(testFilePath);
|
||||
});
|
||||
|
||||
it('uploads multiple files to a file input', async () => {
|
||||
const firstFilePath = path.join(process.cwd(), 'first.txt');
|
||||
const secondFilePath = path.join(process.cwd(), 'second.txt');
|
||||
await fs.writeFile(firstFilePath, 'first file content');
|
||||
await fs.writeFile(secondFilePath, 'second file content');
|
||||
|
||||
await withMcpContext(async (response, context) => {
|
||||
const page = context.getSelectedMcpPage().pptrPage;
|
||||
await page.setContent(
|
||||
html`<form>
|
||||
<input
|
||||
type="file"
|
||||
id="file-input"
|
||||
multiple
|
||||
/>
|
||||
</form>`,
|
||||
);
|
||||
context.getSelectedMcpPage().textSnapshot = await TextSnapshot.create(
|
||||
context.getSelectedMcpPage(),
|
||||
);
|
||||
await uploadFile.handler(
|
||||
{
|
||||
params: {
|
||||
uid: '1_2',
|
||||
filePaths: [firstFilePath, secondFilePath],
|
||||
},
|
||||
page: context.getSelectedMcpPage(),
|
||||
},
|
||||
response,
|
||||
context,
|
||||
);
|
||||
assert.strictEqual(
|
||||
response.responseLines[0],
|
||||
`File uploaded from ${firstFilePath}, ${secondFilePath}.`,
|
||||
);
|
||||
const uploadedFileNames = await page.$eval('#file-input', el => {
|
||||
const input = el as HTMLInputElement;
|
||||
return Array.from(input.files ?? []).map(file => file.name);
|
||||
});
|
||||
assert.deepStrictEqual(uploadedFileNames, ['first.txt', 'second.txt']);
|
||||
});
|
||||
|
||||
await fs.unlink(firstFilePath);
|
||||
await fs.unlink(secondFilePath);
|
||||
});
|
||||
|
||||
it('uploads a file when clicking an element opens a file uploader', async () => {
|
||||
const testFilePath = path.join(process.cwd(), 'test.txt');
|
||||
await fs.writeFile(testFilePath, 'test file content');
|
||||
@@ -1276,7 +1322,7 @@ describe('input', () => {
|
||||
{
|
||||
params: {
|
||||
uid: '1_1',
|
||||
filePath: testFilePath,
|
||||
filePaths: [testFilePath],
|
||||
},
|
||||
page: context.getSelectedMcpPage(),
|
||||
},
|
||||
@@ -1314,7 +1360,7 @@ describe('input', () => {
|
||||
{
|
||||
params: {
|
||||
uid: '1_1',
|
||||
filePath: testFilePath,
|
||||
filePaths: [testFilePath],
|
||||
},
|
||||
page: context.getSelectedMcpPage(),
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user