Skip to content

Commit

Permalink
feat: allow folder uploads
Browse files Browse the repository at this point in the history
  • Loading branch information
mxschmitt committed Jun 6, 2024
1 parent 34dac65 commit 4e79b5e
Show file tree
Hide file tree
Showing 12 changed files with 151 additions and 12 deletions.
1 change: 1 addition & 0 deletions docs/src/api/class-elementhandle.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions docs/src/api/class-locator.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**

Expand Down
1 change: 1 addition & 0 deletions docs/src/api/class-page.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
42 changes: 34 additions & 8 deletions packages/playwright-core/src/client/elementHandle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Check failure on line 265 in packages/playwright-core/src/client/elementHandle.ts

View workflow job for this annotation

GitHub Actions / docs & lint

No overload matches this call.
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[] };
}
Expand Down
11 changes: 11 additions & 0 deletions packages/playwright-core/src/protocol/validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
7 changes: 6 additions & 1 deletion packages/playwright-core/src/server/dom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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!);
}
Expand Down
9 changes: 6 additions & 3 deletions packages/playwright-core/types/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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**
*
Expand Down
15 changes: 15 additions & 0 deletions packages/protocol/src/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>;
Expand Down Expand Up @@ -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,
Expand Down
16 changes: 16 additions & 0 deletions packages/protocol/src/protocol.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
12 changes: 12 additions & 0 deletions tests/assets/input/folderupload.html
Original file line number Diff line number Diff line change
@@ -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>
33 changes: 33 additions & 0 deletions tests/page/page-set-input-files.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down

0 comments on commit 4e79b5e

Please sign in to comment.