Skip to content

Commit 8538a47

Browse files
authored
fix(cli): accept multiple files in upload command (#42057)
1 parent 15ce5e8 commit 8538a47

10 files changed

Lines changed: 90 additions & 17 deletions

File tree

docs/src/getting-started-cli.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ playwright-cli check <ref> # check a checkbox or radio button
101101
playwright-cli uncheck <ref> # uncheck a checkbox
102102
playwright-cli hover <ref> # hover over element
103103
playwright-cli drag <startRef> <endRef> # drag and drop between elements
104-
playwright-cli upload <file> # upload files
104+
playwright-cli upload <files...> # upload one or multiple files
105105
playwright-cli close # close the page
106106
```
107107

packages/playwright-core/src/tools/cli-client/program.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -445,9 +445,9 @@ function validateFlags(args: MinimistArgs, command: { flags: Record<string, 'boo
445445
output.errorUnknownOption(unknownFlags, command.help);
446446
}
447447

448-
function validateArgs(args: MinimistArgs, command: { args: string[], help: string }, output: Output) {
448+
function validateArgs(args: MinimistArgs, command: { args: string[], variadicArg?: boolean, help: string }, output: Output) {
449449
const positional = args._.slice(1);
450-
if (positional.length > command.args.length)
450+
if (positional.length > command.args.length && !command.variadicArg)
451451
output.errorTooManyArguments(command.args.length, positional.length, command.help);
452452
}
453453

packages/playwright-core/src/tools/cli-daemon/command.ts

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,19 @@ export function declareCommand<Args extends zodType.ZodTypeAny, Options extends
4040
const kEmptyOptions = z.object({});
4141
const kEmptyArgs = z.object({});
4242

43+
// The last positional argument absorbs the remaining argv when its schema accepts an array.
44+
export function isVariadicArg(schema: zodType.ZodTypeAny): boolean {
45+
if (schema instanceof z.ZodArray)
46+
return true;
47+
if (schema instanceof z.ZodUnion)
48+
return schema.options.some(option => isVariadicArg(option as zodType.ZodTypeAny));
49+
if (schema instanceof z.ZodOptional)
50+
return isVariadicArg(schema.unwrap() as zodType.ZodTypeAny);
51+
if (schema instanceof z.ZodPipe)
52+
return isVariadicArg(schema.in as zodType.ZodTypeAny);
53+
return false;
54+
}
55+
4356
export function parseCommand(command: AnyCommandSchema, args: Record<string, string> & { _: string[] }): { toolName: string, toolParams: any } {
4457
const optionsObject = { ...args } as Record<string, string>;
4558
delete optionsObject['_'];
@@ -49,11 +62,17 @@ export function parseCommand(command: AnyCommandSchema, args: Record<string, str
4962
const argsSchema = (command.args ?? kEmptyArgs).strict();
5063
const argNames = [...Object.keys(argsSchema.shape)];
5164
const argv = args['_'].slice(1);
52-
if (argv.length > argNames.length)
65+
const variadic = argNames.length > 0 && isVariadicArg(argsSchema.shape[argNames[argNames.length - 1]]);
66+
if (argv.length > argNames.length && !variadic)
5367
throw new Error(`error: too many arguments: expected ${argNames.length}, received ${argv.length}`);
54-
const argsObject: Record<string, string> = {};
55-
argNames.forEach((name, index) => argsObject[name] = argv[index]);
56-
const parsedArgsObject: Record<string, string> = zodParse(argsSchema, argsObject, 'argument');
68+
const argsObject: Record<string, string | string[] | undefined> = {};
69+
argNames.forEach((name, index) => {
70+
if (variadic && index === argNames.length - 1)
71+
argsObject[name] = index < argv.length ? argv.slice(index) : undefined;
72+
else
73+
argsObject[name] = argv[index];
74+
});
75+
const parsedArgsObject: Record<string, string | string[]> = zodParse(argsSchema, argsObject, 'argument');
5776

5877
const toolName = typeof command.toolName === 'function' ? command.toolName({ ...parsedArgsObject, ...options }) : command.toolName;
5978
const toolParams = command.toolParams({ ...parsedArgsObject, ...options });
@@ -72,6 +91,10 @@ function zodParse(schema: zodType.ZodAny, data: unknown, type: 'option' | 'argum
7291
switch (issue.code) {
7392
case 'invalid_type':
7493
return 'error: ' + label + ': ' + issue.message.replace(/Invalid input:/, '').trim();
94+
case 'invalid_union': {
95+
const message = issue.errors[0]?.[0]?.message ?? issue.message;
96+
return 'error: ' + label + ': ' + message.replace(/Invalid input:/, '').trim();
97+
}
7598
case 'unrecognized_keys':
7699
return 'error: unknown ' + label;
77100
default:

packages/playwright-core/src/tools/cli-daemon/commands.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -343,10 +343,10 @@ const fileUpload = declareCommand({
343343
description: 'Upload one or multiple files',
344344
category: 'core',
345345
args: z.object({
346-
file: z.string().describe('The absolute paths to the files to upload'),
346+
files: stringArrayArg.describe('The absolute paths to the files to upload'),
347347
}),
348348
toolName: 'browser_file_upload',
349-
toolParams: ({ file }) => ({ paths: [file] }),
349+
toolParams: ({ files }) => ({ paths: files }),
350350
});
351351

352352
const check = declareCommand({

packages/playwright-core/src/tools/cli-daemon/helpGenerator.ts

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,25 +16,33 @@
1616

1717
import * as z from 'zod';
1818
import { commands } from './commands';
19+
import { isVariadicArg } from './command';
1920

2021
import type zodType from 'zod';
2122
import type { AnyCommandSchema, Category } from './command';
2223

23-
type CommandArg = { name: string, description: string, optional: boolean };
24+
type CommandArg = { name: string, description: string, optional: boolean, variadic: boolean };
2425

2526
function commandArgs(command: AnyCommandSchema): CommandArg[] {
2627
const args: CommandArg[] = [];
2728
const shape = command.args ? (command.args as zodType.ZodObject<any>).shape : {};
29+
const names = Object.keys(shape);
2830
for (const [name, schema] of Object.entries(shape)) {
2931
const zodSchema = schema as zodType.ZodTypeAny;
3032
const description = zodSchema.description ?? '';
31-
args.push({ name, description, optional: zodSchema.safeParse(undefined).success });
33+
const variadic = name === names[names.length - 1] && isVariadicArg(zodSchema);
34+
args.push({ name, description, optional: zodSchema.safeParse(undefined).success, variadic });
3235
}
3336
return args;
3437
}
3538

39+
function commandArgText(a: CommandArg) {
40+
const name = a.variadic ? `${a.name}...` : a.name;
41+
return a.optional ? `[${name}]` : `<${name}>`;
42+
}
43+
3644
function commandArgsText(args: CommandArg[]) {
37-
return args.map(a => a.optional ? `[${a.name}]` : `<${a.name}>`).join(' ');
45+
return args.map(commandArgText).join(' ');
3846
}
3947

4048
function generateCommandHelp(command: AnyCommandSchema) {
@@ -48,7 +56,7 @@ function generateCommandHelp(command: AnyCommandSchema) {
4856

4957
if (args.length) {
5058
lines.push('Arguments:');
51-
lines.push(...args.map(a => formatWithGap(` ${a.optional ? `[${a.name}]` : `<${a.name}>`}`, a.description.toLowerCase())));
59+
lines.push(...args.map(a => formatWithGap(` ${commandArgText(a)}`, a.description.toLowerCase())));
5260
}
5361

5462
if (command.options) {
@@ -165,7 +173,7 @@ function isBooleanSchema(schema: zodType.ZodTypeAny): boolean {
165173
export function generateHelpJSON() {
166174
const booleanOptions = new Set<string>();
167175

168-
const commandEntries: Record<string, { help: string, flags: Record<string, 'boolean' | 'string'>, args: string[], raw?: boolean }> = {};
176+
const commandEntries: Record<string, { help: string, flags: Record<string, 'boolean' | 'string'>, args: string[], variadicArg?: boolean, raw?: boolean }> = {};
169177
for (const [name, command] of Object.entries(commands)) {
170178
const flags: Record<string, 'boolean' | 'string'> = {};
171179
if (command.options) {
@@ -177,8 +185,10 @@ export function generateHelpJSON() {
177185
booleanOptions.add(flagName);
178186
}
179187
}
180-
const args: string[] = command.args ? Object.keys((command.args as zodType.ZodObject<any>).shape) : [];
181-
commandEntries[name] = { help: generateCommandHelp(command), flags, args };
188+
const args = commandArgs(command);
189+
commandEntries[name] = { help: generateCommandHelp(command), flags, args: args.map(a => a.name) };
190+
if (args.some(a => a.variadic))
191+
commandEntries[name].variadicArg = true;
182192
if (command.raw)
183193
commandEntries[name].raw = true;
184194
}

packages/playwright-core/src/tools/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ export { compareSemver } from './utils/socketConnection';
3030
export { extractTrace, DirTraceLoaderBackend } from './trace/traceParser';
3131
export { decorateMCPCommand } from './mcp/program';
3232
export { program as cliProgram } from './cli-client/program';
33-
export { generateHelp, generateHelpJSON } from './cli-daemon/helpGenerator';
33+
export { generateHelp, generateHelpJSON, generateReadme } from './cli-daemon/helpGenerator';
3434
export { decorateProgram as decorateCliDaemonProgram, initWorkspace } from './cli-daemon/program';
3535
export { allSkills, installSkills } from './utils/installSkills';
3636
export { openDashboardApp, openDashboardForContext } from './dashboard/dashboardApp';

tests/mcp/cli-core.spec.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,33 @@ test('uncheck', async ({ cli, server, mcpBrowser }) => {
146146
expect(inlineSnapshot).toContain(`- checkbox ${active}[ref=e2]`);
147147
});
148148

149+
test('upload multiple files', { annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/42047' } }, async ({ cli, server }, testInfo) => {
150+
server.setContent('/', `
151+
<input type="file" multiple>
152+
<div id="result"></div>
153+
<script>
154+
document.querySelector('input').addEventListener('change', event => {
155+
document.getElementById('result').textContent = 'Received: ' + [...event.target.files].map(f => f.name).join(', ');
156+
});
157+
</script>
158+
`, 'text/html');
159+
160+
const front = testInfo.outputPath('front.txt');
161+
const back = testInfo.outputPath('back.txt');
162+
await fs.promises.writeFile(front, 'front');
163+
await fs.promises.writeFile(back, 'back');
164+
165+
await cli('open', server.PREFIX);
166+
await cli('click', 'e2');
167+
const { output, snapshot } = await cli('upload', front, back);
168+
expect(output).toContain('await fileChooser.setFiles(');
169+
expect(snapshot).toContain('Received: front.txt, back.txt');
170+
171+
await cli('click', 'e2');
172+
const single = await cli('upload', back);
173+
expect(single.snapshot).toContain('Received: back.txt');
174+
});
175+
149176
test('eval', async ({ cli, server }) => {
150177
await cli('open', server.HELLO_WORLD);
151178
const { output } = await cli('eval', '() => document.title');

tests/mcp/cli-help.spec.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,11 @@ test('prints command help', async ({ cli }) => {
3131
expect(output).toContain('playwright-cli click <target> [button]');
3232
});
3333

34+
test('prints variadic command help', { annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/42047' } }, async ({ cli }) => {
35+
const { output } = await cli('upload', '--help');
36+
expect(output).toContain('playwright-cli upload <files...>');
37+
});
38+
3439
test('prints agent skill path when running under a coding agent', async ({ cli }) => {
3540
const { output } = await cli('--help', { env: { CLAUDECODE: '1' } });
3641
expect(output).toContain('Agent skill:');

tests/mcp/cli-parsing.spec.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,13 @@ test('missing argument', async ({ cli, server }) => {
5757
expect(error).toContain(`error: 'key' argument: expected string, received undefined`);
5858
});
5959

60+
test('missing variadic argument', { annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/42047' } }, async ({ cli, server }) => {
61+
await cli('open', server.HELLO_WORLD);
62+
const { error, exitCode } = await cli('upload');
63+
expect(exitCode).toBe(1);
64+
expect(error).toContain(`error: 'files' argument: expected string, received undefined`);
65+
});
66+
6067
test('wrong argument type', async ({ cli, server }) => {
6168
await cli('open', server.HELLO_WORLD);
6269
const { error, exitCode } = await cli('mousemove', '12', 'foo');

utils/build/build.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -946,6 +946,7 @@ steps.push(new ProgramStep({
946946
// Generate CLI help.
947947
onChanges.push({
948948
inputs: [
949+
'packages/playwright-core/src/tools/cli-daemon/command.ts',
949950
'packages/playwright-core/src/tools/cli-daemon/commands.ts',
950951
'packages/playwright-core/src/tools/cli-daemon/helpGenerator.ts',
951952
'utils/generate_cli_help.js',

0 commit comments

Comments
 (0)