diff --git a/docs/src/api/class-elementhandle.md b/docs/src/api/class-elementhandle.md index dbf99eb3a250b3..faeedb4a13305a 100644 --- a/docs/src/api/class-elementhandle.md +++ b/docs/src/api/class-elementhandle.md @@ -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 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 [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. diff --git a/docs/src/api/class-locator.md b/docs/src/api/class-locator.md index af7f8754c753b0..80c57a9a3d362f 100644 --- a/docs/src/api/class-locator.md +++ b/docs/src/api/class-locator.md @@ -2164,6 +2164,7 @@ When all steps combined have not finished during the specified [`option: timeout * since: v1.14 Upload file or multiple files into `<input type=file>`. +In order to upload a directory (`[webkitdirectory]`) pass the path to the directory. **Usage** diff --git a/docs/src/api/class-page.md b/docs/src/api/class-page.md index b4ee91eb1b3019..0b722dd07a913e 100644 --- a/docs/src/api/class-page.md +++ b/docs/src/api/class-page.md @@ -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 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 [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. diff --git a/packages/playwright-core/src/client/elementHandle.ts b/packages/playwright-core/src/client/elementHandle.ts index 9315d50eaeeaa9..e7fbcca0fce904 100644 --- a/packages/playwright-core/src/client/elementHandle.ts +++ b/packages/playwright-core/src/client/elementHandle.ts @@ -262,16 +262,42 @@ export async function convertInputFiles(files: string | FilePayload | string[] | if (items.some(item => typeof item === 'string')) { if (!items.every(item => typeof item === 'string')) throw new Error('File paths cannot be mixed with buffers'); + const directoryCount = (await Promise.all(items.map(async item => (await fs.promises.stat(item)).isDirectory()))).filter(Boolean).length; + if (directoryCount > 1) + throw new Error('Only one directory can be uploaded at a time'); if (context._connection.isRemote()) { - const streams: channels.WritableStreamChannel[] = await Promise.all((items as string[]).map(async item => { - const lastModifiedMs = (await fs.promises.stat(item)).mtimeMs; - const { writableStream: stream } = await context._wrapApiCall(() => context._channel.createTempFile({ name: path.basename(item), lastModifiedMs }), true); - const writable = WritableStream.from(stream); - await pipelineAsync(fs.createReadStream(item), writable.stream()); - return stream; - })); - return { streams }; + let streams: channels.WritableStreamChannel[] | undefined; + let localPaths: string[] | undefined; + for (const item of items as string[]) { + if ((await fs.promises.stat(item)).isDirectory()) { + const files = (await fs.promises.readdir(item, { withFileTypes: true, recursive: true })).filter(f => f.isFile()).map(f => path.join(item, f.name)); + const { writableStreams, dir } = await context._wrapApiCall(async () => context._channel.createTempDirectory({ + root: item, + 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[] }; } diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index 0a60a92ebb480f..35582ecce9e761 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -958,6 +958,17 @@ scheme.BrowserContextCreateTempFileParams = tObject({ scheme.BrowserContextCreateTempFileResult = tObject({ 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({ event: tEnum(['console', 'dialog', 'request', 'response', 'requestFinished', 'requestFailed']), enabled: tBoolean, diff --git a/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts b/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts index dd1f61f57b531f..360b52456288b5 100644 --- a/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts @@ -187,6 +187,21 @@ export class BrowserContextDispatcher extends Dispatcher<BrowserContext, channel 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) { this._context.setDefaultNavigationTimeout(params.timeout); } diff --git a/packages/playwright-core/src/server/dom.ts b/packages/playwright-core/src/server/dom.ts index 01e70f16375ebe..a9942df66a39c0 100644 --- a/packages/playwright-core/src/server/dom.ts +++ b/packages/playwright-core/src/server/dom.ts @@ -633,7 +633,7 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> { return; if (element.tagName !== 'INPUT') 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'); return element; }, multiple); @@ -647,7 +647,12 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> { await Promise.all(localPaths.map(localPath => ( 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 waitForChangeEvent; } else { await this._page._delegate.setInputFiles(retargeted, filePayloads!); } diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index dc91c655f09adc..827bcc2649dace 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -4055,7 +4055,8 @@ export interface Page { * 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 - * 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 * [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). * * 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 * [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>; /** - * 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** * diff --git a/packages/protocol/src/channels.ts b/packages/protocol/src/channels.ts index c8523e8de5c2cf..60f6d055bdda35 100644 --- a/packages/protocol/src/channels.ts +++ b/packages/protocol/src/channels.ts @@ -1459,6 +1459,7 @@ export interface BrowserContextChannel extends BrowserContextEventTarget, EventT harStart(params: BrowserContextHarStartParams, metadata?: CallMetadata): Promise<BrowserContextHarStartResult>; harExport(params: BrowserContextHarExportParams, metadata?: CallMetadata): Promise<BrowserContextHarExportResult>; createTempFile(params: BrowserContextCreateTempFileParams, metadata?: CallMetadata): Promise<BrowserContextCreateTempFileResult>; + createTempDirectory(params: BrowserContextCreateTempDirectoryParams, metadata?: CallMetadata): Promise<BrowserContextCreateTempDirectoryResult>; updateSubscription(params: BrowserContextUpdateSubscriptionParams, metadata?: CallMetadata): Promise<BrowserContextUpdateSubscriptionResult>; clockInstallFakeTimers(params: BrowserContextClockInstallFakeTimersParams, metadata?: CallMetadata): Promise<BrowserContextClockInstallFakeTimersResult>; clockRunAllTimers(params?: BrowserContextClockRunAllTimersParams, metadata?: CallMetadata): Promise<BrowserContextClockRunAllTimersResult>; @@ -1747,6 +1748,20 @@ export type BrowserContextCreateTempFileOptions = { export type BrowserContextCreateTempFileResult = { 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 = { event: 'console' | 'dialog' | 'request' | 'response' | 'requestFinished' | 'requestFailed', enabled: boolean, diff --git a/packages/protocol/src/protocol.yml b/packages/protocol/src/protocol.yml index 7f6bd3bfe1adec..8d2b9216f81981 100644 --- a/packages/protocol/src/protocol.yml +++ b/packages/protocol/src/protocol.yml @@ -1191,6 +1191,22 @@ BrowserContext: returns: 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: parameters: event: diff --git a/tests/assets/input/folderupload.html b/tests/assets/input/folderupload.html new file mode 100644 index 00000000000000..16c7e2c3e91bce --- /dev/null +++ b/tests/assets/input/folderupload.html @@ -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> \ No newline at end of file diff --git a/tests/page/page-set-input-files.spec.ts b/tests/page/page-set-input-files.spec.ts index 5777b1087a624b..21f1c253116b96 100644 --- a/tests/page/page-set-input-files.spec.ts +++ b/tests/page/page-set-input-files.spec.ts @@ -37,6 +37,39 @@ it('should upload the file', async ({ page, server, asset }) => { }, 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.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/29923' }); await page.goto(server.PREFIX + '/input/fileupload.html');