feat: allow folder uploads
This commit is contained in:
parent
43d6d012d4
commit
cfa92b479d
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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**
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 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()) {
|
||||
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[] };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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!);
|
||||
}
|
||||
|
|
|
|||
9
packages/playwright-core/types/types.d.ts
vendored
9
packages/playwright-core/types/types.d.ts
vendored
|
|
@ -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**
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
12
tests/assets/input/folderupload.html
Normal file
12
tests/assets/input/folderupload.html
Normal 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>
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Reference in a new issue