feat: allow folder uploads

This commit is contained in:
Max Schmitt 2024-06-06 19:52:59 +02:00
parent 43d6d012d4
commit cfa92b479d
12 changed files with 151 additions and 12 deletions

View file

@ -953,6 +953,7 @@ When all steps combined have not finished during the specified [`option: timeout
Sets the value of the file input to these file paths or files. If some of the `filePaths` are relative paths, then they Sets the value of the file input to these file paths or files. If some of the `filePaths` are relative paths, then they
are resolved relative to the current working directory. For empty array, clears the selected files. are resolved relative to the current working directory. For empty array, clears the selected files.
In order to upload a directory (`[webkitdirectory]`) pass the path to the directory.
This method expects [ElementHandle] to point to an This method expects [ElementHandle] to point to an
[input element](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input). However, if the element is inside the `<label>` element that has an associated [control](https://developer.mozilla.org/en-US/docs/Web/API/HTMLLabelElement/control), targets the control instead. [input element](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input). However, if the element is inside the `<label>` element that has an associated [control](https://developer.mozilla.org/en-US/docs/Web/API/HTMLLabelElement/control), targets the control instead.

View file

@ -2164,6 +2164,7 @@ When all steps combined have not finished during the specified [`option: timeout
* since: v1.14 * since: v1.14
Upload file or multiple files into `<input type=file>`. Upload file or multiple files into `<input type=file>`.
In order to upload a directory (`[webkitdirectory]`) pass the path to the directory.
**Usage** **Usage**

View file

@ -3927,6 +3927,7 @@ An object containing additional HTTP headers to be sent with every request. All
Sets the value of the file input to these file paths or files. If some of the `filePaths` are relative paths, then they Sets the value of the file input to these file paths or files. If some of the `filePaths` are relative paths, then they
are resolved relative to the current working directory. For empty array, clears the selected files. are resolved relative to the current working directory. For empty array, clears the selected files.
In order to upload a directory (`[webkitdirectory]`) pass the path to the directory.
This method expects [`param: selector`] to point to an This method expects [`param: selector`] to point to an
[input element](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input). However, if the element is inside the `<label>` element that has an associated [control](https://developer.mozilla.org/en-US/docs/Web/API/HTMLLabelElement/control), targets the control instead. [input element](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input). However, if the element is inside the `<label>` element that has an associated [control](https://developer.mozilla.org/en-US/docs/Web/API/HTMLLabelElement/control), targets the control instead.

View file

@ -262,16 +262,42 @@ export async function convertInputFiles(files: string | FilePayload | string[] |
if (items.some(item => typeof item === 'string')) { if (items.some(item => typeof item === 'string')) {
if (!items.every(item => typeof item === 'string')) if (!items.every(item => typeof item === 'string'))
throw new Error('File paths cannot be mixed with buffers'); throw new Error('File paths cannot be mixed with buffers');
const directoryCount = (await Promise.all(items.map(async item => (await fs.promises.stat(item as string)).isDirectory()))).filter(Boolean).length;
if (directoryCount > 1)
throw new Error('Only one directory can be uploaded at a time');
if (context._connection.isRemote()) { if (context._connection.isRemote()) {
const streams: channels.WritableStreamChannel[] = await Promise.all((items as string[]).map(async item => { let streams: channels.WritableStreamChannel[] | undefined;
const lastModifiedMs = (await fs.promises.stat(item)).mtimeMs; let localPaths: string[] | undefined;
const { writableStream: stream } = await context._wrapApiCall(() => context._channel.createTempFile({ name: path.basename(item), lastModifiedMs }), true); for (const item of items as string[]) {
const writable = WritableStream.from(stream); if ((await fs.promises.stat(item)).isDirectory()) {
await pipelineAsync(fs.createReadStream(item), writable.stream()); const files = (await fs.promises.readdir(item, { withFileTypes: true, recursive: true })).filter(f => f.isFile()).map(f => path.join(item, f.name));
return stream; const { writableStreams, dir } = await context._wrapApiCall(async () => context._channel.createTempDirectory({
})); root: item,
return { streams }; items: await Promise.all(files.map(async f => {
const lastModifiedMs = (await fs.promises.stat(f)).mtimeMs;
return {
name: path.relative(item, f),
lastModifiedMs
};
})),
}), true);
for (let i = 0; i < files.length; i++) {
const writable = WritableStream.from(writableStreams[i]);
await pipelineAsync(fs.createReadStream(files[i]), writable.stream());
}
localPaths ??= [];
localPaths.push(dir);
} else {
const lastModifiedMs = (await fs.promises.stat(item)).mtimeMs;
const { writableStream } = await context._wrapApiCall(() => context._channel.createTempFile({ name: path.basename(item), lastModifiedMs }), true);
const writable = WritableStream.from(writableStream);
await pipelineAsync(fs.createReadStream(item), writable.stream());
streams ??= [];
streams.push(writableStream);
}
}
return { streams, localPaths };
} }
return { localPaths: items.map(f => path.resolve(f as string)) as string[] }; return { localPaths: items.map(f => path.resolve(f as string)) as string[] };
} }

View file

@ -958,6 +958,17 @@ scheme.BrowserContextCreateTempFileParams = tObject({
scheme.BrowserContextCreateTempFileResult = tObject({ scheme.BrowserContextCreateTempFileResult = tObject({
writableStream: tChannel(['WritableStream']), writableStream: tChannel(['WritableStream']),
}); });
scheme.BrowserContextCreateTempDirectoryParams = tObject({
root: tString,
items: tArray(tObject({
name: tString,
lastModifiedMs: tOptional(tNumber),
})),
});
scheme.BrowserContextCreateTempDirectoryResult = tObject({
dir: tString,
writableStreams: tArray(tChannel(['WritableStream'])),
});
scheme.BrowserContextUpdateSubscriptionParams = tObject({ scheme.BrowserContextUpdateSubscriptionParams = tObject({
event: tEnum(['console', 'dialog', 'request', 'response', 'requestFinished', 'requestFailed']), event: tEnum(['console', 'dialog', 'request', 'response', 'requestFinished', 'requestFailed']),
enabled: tBoolean, enabled: tBoolean,

View file

@ -187,6 +187,21 @@ export class BrowserContextDispatcher extends Dispatcher<BrowserContext, channel
return { writableStream: new WritableStreamDispatcher(this, file, params.lastModifiedMs) }; return { writableStream: new WritableStreamDispatcher(this, file, params.lastModifiedMs) };
} }
async createTempDirectory(params: channels.BrowserContextCreateTempDirectoryParams): Promise<channels.BrowserContextCreateTempDirectoryResult> {
const dir = this._context._browser.options.artifactsDir;
const tmpDir = path.join(dir, 'upload-' + createGuid());
const tempDirWithRootName = path.join(tmpDir, path.basename(params.root));
await fs.promises.mkdir(tempDirWithRootName, { recursive: true });
return {
dir: tempDirWithRootName,
writableStreams: await Promise.all(params.items.map(async item => {
await fs.promises.mkdir(path.dirname(path.join(tempDirWithRootName, item.name)), { recursive: true });
const file = fs.createWriteStream(path.join(tempDirWithRootName, item.name));
return new WritableStreamDispatcher(this, file, item.lastModifiedMs);
}))
};
}
async setDefaultNavigationTimeoutNoReply(params: channels.BrowserContextSetDefaultNavigationTimeoutNoReplyParams) { async setDefaultNavigationTimeoutNoReply(params: channels.BrowserContextSetDefaultNavigationTimeoutNoReplyParams) {
this._context.setDefaultNavigationTimeout(params.timeout); this._context.setDefaultNavigationTimeout(params.timeout);
} }

View file

@ -633,7 +633,7 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
return; return;
if (element.tagName !== 'INPUT') if (element.tagName !== 'INPUT')
throw injected.createStacklessError('Node is not an HTMLInputElement'); throw injected.createStacklessError('Node is not an HTMLInputElement');
if (multiple && !(element as HTMLInputElement).multiple) if (multiple && !(element as HTMLInputElement).multiple && !(element as HTMLInputElement).webkitdirectory)
throw injected.createStacklessError('Non-multiple file input can only accept single file'); throw injected.createStacklessError('Non-multiple file input can only accept single file');
return element; return element;
}, multiple); }, multiple);
@ -647,7 +647,12 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
await Promise.all(localPaths.map(localPath => ( await Promise.all(localPaths.map(localPath => (
fs.promises.access(localPath, fs.constants.F_OK) fs.promises.access(localPath, fs.constants.F_OK)
))); )));
const isDirectoryUpload = localPaths.length === 1 ? (await fs.promises.stat(localPaths[0])).isDirectory() : false;
const waitForChangeEvent = isDirectoryUpload ? this.evaluateInUtility(([_, node]) => new Promise<any>(fulfil => {
node.addEventListener('change', fulfil, { once: true });
}), undefined) : Promise.resolve();
await this._page._delegate.setInputFilePaths(retargeted, localPaths); await this._page._delegate.setInputFilePaths(retargeted, localPaths);
await waitForChangeEvent;
} else { } else {
await this._page._delegate.setInputFiles(retargeted, filePayloads!); await this._page._delegate.setInputFiles(retargeted, filePayloads!);
} }

View file

@ -4055,7 +4055,8 @@ export interface Page {
* instead. Read more about [locators](https://playwright.dev/docs/locators). * instead. Read more about [locators](https://playwright.dev/docs/locators).
* *
* Sets the value of the file input to these file paths or files. If some of the `filePaths` are relative paths, then * Sets the value of the file input to these file paths or files. If some of the `filePaths` are relative paths, then
* they are resolved relative to the current working directory. For empty array, clears the selected files. * they are resolved relative to the current working directory. For empty array, clears the selected files. In order
* to upload a directory (`[webkitdirectory]`) pass the path to the directory.
* *
* This method expects `selector` to point to an * This method expects `selector` to point to an
* [input element](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input). However, if the element is inside * [input element](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input). However, if the element is inside
@ -10579,7 +10580,8 @@ export interface ElementHandle<T=Node> extends JSHandle<T> {
* instead. Read more about [locators](https://playwright.dev/docs/locators). * instead. Read more about [locators](https://playwright.dev/docs/locators).
* *
* Sets the value of the file input to these file paths or files. If some of the `filePaths` are relative paths, then * Sets the value of the file input to these file paths or files. If some of the `filePaths` are relative paths, then
* they are resolved relative to the current working directory. For empty array, clears the selected files. * they are resolved relative to the current working directory. For empty array, clears the selected files. In order
* to upload a directory (`[webkitdirectory]`) pass the path to the directory.
* *
* This method expects {@link ElementHandle} to point to an * This method expects {@link ElementHandle} to point to an
* [input element](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input). However, if the element is inside * [input element](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input). However, if the element is inside
@ -12786,7 +12788,8 @@ export interface Locator {
}): Promise<void>; }): Promise<void>;
/** /**
* Upload file or multiple files into `<input type=file>`. * Upload file or multiple files into `<input type=file>`. In order to upload a directory (`[webkitdirectory]`) pass
* the path to the directory.
* *
* **Usage** * **Usage**
* *

View file

@ -1459,6 +1459,7 @@ export interface BrowserContextChannel extends BrowserContextEventTarget, EventT
harStart(params: BrowserContextHarStartParams, metadata?: CallMetadata): Promise<BrowserContextHarStartResult>; harStart(params: BrowserContextHarStartParams, metadata?: CallMetadata): Promise<BrowserContextHarStartResult>;
harExport(params: BrowserContextHarExportParams, metadata?: CallMetadata): Promise<BrowserContextHarExportResult>; harExport(params: BrowserContextHarExportParams, metadata?: CallMetadata): Promise<BrowserContextHarExportResult>;
createTempFile(params: BrowserContextCreateTempFileParams, metadata?: CallMetadata): Promise<BrowserContextCreateTempFileResult>; createTempFile(params: BrowserContextCreateTempFileParams, metadata?: CallMetadata): Promise<BrowserContextCreateTempFileResult>;
createTempDirectory(params: BrowserContextCreateTempDirectoryParams, metadata?: CallMetadata): Promise<BrowserContextCreateTempDirectoryResult>;
updateSubscription(params: BrowserContextUpdateSubscriptionParams, metadata?: CallMetadata): Promise<BrowserContextUpdateSubscriptionResult>; updateSubscription(params: BrowserContextUpdateSubscriptionParams, metadata?: CallMetadata): Promise<BrowserContextUpdateSubscriptionResult>;
clockInstallFakeTimers(params: BrowserContextClockInstallFakeTimersParams, metadata?: CallMetadata): Promise<BrowserContextClockInstallFakeTimersResult>; clockInstallFakeTimers(params: BrowserContextClockInstallFakeTimersParams, metadata?: CallMetadata): Promise<BrowserContextClockInstallFakeTimersResult>;
clockRunAllTimers(params?: BrowserContextClockRunAllTimersParams, metadata?: CallMetadata): Promise<BrowserContextClockRunAllTimersResult>; clockRunAllTimers(params?: BrowserContextClockRunAllTimersParams, metadata?: CallMetadata): Promise<BrowserContextClockRunAllTimersResult>;
@ -1747,6 +1748,20 @@ export type BrowserContextCreateTempFileOptions = {
export type BrowserContextCreateTempFileResult = { export type BrowserContextCreateTempFileResult = {
writableStream: WritableStreamChannel, writableStream: WritableStreamChannel,
}; };
export type BrowserContextCreateTempDirectoryParams = {
root: string,
items: {
name: string,
lastModifiedMs?: number,
}[],
};
export type BrowserContextCreateTempDirectoryOptions = {
};
export type BrowserContextCreateTempDirectoryResult = {
dir: string,
writableStreams: WritableStreamChannel[],
};
export type BrowserContextUpdateSubscriptionParams = { export type BrowserContextUpdateSubscriptionParams = {
event: 'console' | 'dialog' | 'request' | 'response' | 'requestFinished' | 'requestFailed', event: 'console' | 'dialog' | 'request' | 'response' | 'requestFinished' | 'requestFailed',
enabled: boolean, enabled: boolean,

View file

@ -1191,6 +1191,22 @@ BrowserContext:
returns: returns:
writableStream: WritableStream writableStream: WritableStream
createTempDirectory:
parameters:
root: string
items:
type: array
items:
type: object
properties:
name: string
lastModifiedMs: number?
returns:
dir: string
writableStreams:
type: array
items: WritableStream
updateSubscription: updateSubscription:
parameters: parameters:
event: event:

View file

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html>
<head>
<title>Folder upload test</title>
</head>
<body>
<form action="/upload" method="post" enctype="multipart/form-data">
<input type="file" name="file1" webkitdirectory>
<input type="submit">
</form>
</body>
</html>

View file

@ -37,6 +37,39 @@ it('should upload the file', async ({ page, server, asset }) => {
}, input)).toBe('contents of the file'); }, input)).toBe('contents of the file');
}); });
async function createTestDirectoryStructure() {
const baseDir = path.join(it.info().outputDir, 'file-upload-test');
await fs.promises.mkdir(baseDir, { recursive: true });
await fs.promises.writeFile(path.join(baseDir, 'file1.txt'), 'file1 content');
await fs.promises.writeFile(path.join(baseDir, 'file2'), 'file2 content');
await fs.promises.mkdir(path.join(baseDir, 'sub-dir'));
await fs.promises.writeFile(path.join(baseDir, 'sub-dir', 'really.txt'), 'sub-dir file content');
return baseDir;
}
it('should upload a folder', async ({ page, server, browserName, headless, browserMajorVersion }) => {
await page.goto(server.PREFIX + '/input/folderupload.html');
const input = await page.$('input');
const dir = await createTestDirectoryStructure();
await input.setInputFiles(dir);
expect(new Set(await page.evaluate(e => [...e.files].map(f => f.webkitRelativePath), input))).toEqual(new Set([
// https://issues.chromium.org/issues/345393164
...((browserName === 'chromium' && headless && browserMajorVersion < 128) ? [] : ['file-upload-test/sub-dir/really.txt']),
'file-upload-test/file1.txt',
'file-upload-test/file2',
]));
const webkitRelativePaths = await page.evaluate(e => [...e.files].map(f => f.webkitRelativePath), input);
for (let i = 0; i < webkitRelativePaths.length; i++) {
const content = await input.evaluate((e, i) => {
const reader = new FileReader();
const promise = new Promise(fulfill => reader.onload = fulfill);
reader.readAsText(e.files[i]);
return promise.then(() => reader.result);
}, i);
expect(content).toEqual(fs.readFileSync(path.join(dir, '..', webkitRelativePaths[i])).toString());
}
});
it('should upload a file after popup', async ({ page, server, asset }) => { it('should upload a file after popup', async ({ page, server, asset }) => {
it.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/29923' }); it.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/29923' });
await page.goto(server.PREFIX + '/input/fileupload.html'); await page.goto(server.PREFIX + '/input/fileupload.html');