feat: allow folder uploads (#31165)

This commit is contained in:
Max Schmitt 2024-06-12 22:20:18 +02:00 committed by GitHub
parent 751a41f9ee
commit dcf4e4e054
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 245 additions and 59 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.
For inputs with a `[webkitdirectory]` attribute, only a single directory path is supported.
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>`.
For inputs with a `[webkitdirectory]` attribute, only a single directory path is supported.
**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.
For inputs with a `[webkitdirectory]` attribute, only a single directory path is supported.
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

@ -250,12 +250,31 @@ export function convertSelectOptionValues(values: string | api.ElementHandle | S
return { options: values as SelectOption[] }; return { options: values as SelectOption[] };
} }
type SetInputFilesFiles = Pick<channels.ElementHandleSetInputFilesParams, 'payloads' | 'localPaths' | 'streams'>; type SetInputFilesFiles = Pick<channels.ElementHandleSetInputFilesParams, 'payloads' | 'localPaths' | 'localDirectory' | 'streams' | 'directoryStream'>;
function filePayloadExceedsSizeLimit(payloads: FilePayload[]) { function filePayloadExceedsSizeLimit(payloads: FilePayload[]) {
return payloads.reduce((size, item) => size + (item.buffer ? item.buffer.byteLength : 0), 0) >= fileUploadSizeLimit; return payloads.reduce((size, item) => size + (item.buffer ? item.buffer.byteLength : 0), 0) >= fileUploadSizeLimit;
} }
async function resolvePathsAndDirectoryForInputFiles(items: string[]): Promise<[string[] | undefined, string | undefined]> {
let localPaths: string[] | undefined;
let localDirectory: string | undefined;
for (const item of items) {
const stat = await fs.promises.stat(item as string);
if (stat.isDirectory()) {
if (localDirectory)
throw new Error('Multiple directories are not supported');
localDirectory = path.resolve(item as string);
} else {
localPaths ??= [];
localPaths.push(path.resolve(item as string));
}
}
if (localPaths?.length && localDirectory)
throw new Error('File paths must be all files or a single directory');
return [localPaths, localDirectory];
}
export async function convertInputFiles(files: string | FilePayload | string[] | FilePayload[], context: BrowserContext): Promise<SetInputFilesFiles> { export async function convertInputFiles(files: string | FilePayload | string[] | FilePayload[], context: BrowserContext): Promise<SetInputFilesFiles> {
const items: (string | FilePayload)[] = Array.isArray(files) ? files.slice() : [files]; const items: (string | FilePayload)[] = Array.isArray(files) ? files.slice() : [files];
@ -263,17 +282,33 @@ export async function convertInputFiles(files: string | FilePayload | 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 [localPaths, localDirectory] = await resolvePathsAndDirectoryForInputFiles(items as string[]);
if (context._connection.isRemote()) { if (context._connection.isRemote()) {
const streams: channels.WritableStreamChannel[] = await Promise.all((items as string[]).map(async item => { const files = localDirectory ? (await fs.promises.readdir(localDirectory, { withFileTypes: true, recursive: true })).filter(f => f.isFile()).map(f => path.join(f.path, f.name)) : localPaths!;
const lastModifiedMs = (await fs.promises.stat(item)).mtimeMs; const { writableStreams, rootDir } = await context._wrapApiCall(async () => context._channel.createTempFiles({
const { writableStream: stream } = await context._wrapApiCall(() => context._channel.createTempFile({ name: path.basename(item), lastModifiedMs }), true); rootDirName: localDirectory ? path.basename(localDirectory as string) : undefined,
const writable = WritableStream.from(stream); items: await Promise.all(files.map(async file => {
await pipelineAsync(fs.createReadStream(item), writable.stream()); const lastModifiedMs = (await fs.promises.stat(file)).mtimeMs;
return stream; return {
})); name: localDirectory ? path.relative(localDirectory as string, file) : path.basename(file),
return { streams }; lastModifiedMs
};
})),
}), true);
for (let i = 0; i < files.length; i++) {
const writable = WritableStream.from(writableStreams[i]);
await pipelineAsync(fs.createReadStream(files[i]), writable.stream());
}
return {
directoryStream: rootDir,
streams: localDirectory ? undefined : writableStreams,
};
} }
return { localPaths: items.map(f => path.resolve(f as string)) as string[] }; return {
localPaths,
localDirectory,
};
} }
const payloads = items as FilePayload[]; const payloads = items as FilePayload[];

View File

@ -951,12 +951,16 @@ scheme.BrowserContextHarExportParams = tObject({
scheme.BrowserContextHarExportResult = tObject({ scheme.BrowserContextHarExportResult = tObject({
artifact: tChannel(['Artifact']), artifact: tChannel(['Artifact']),
}); });
scheme.BrowserContextCreateTempFileParams = tObject({ scheme.BrowserContextCreateTempFilesParams = tObject({
name: tString, rootDirName: tOptional(tString),
lastModifiedMs: tOptional(tNumber), items: tArray(tObject({
name: tString,
lastModifiedMs: tOptional(tNumber),
})),
}); });
scheme.BrowserContextCreateTempFileResult = tObject({ scheme.BrowserContextCreateTempFilesResult = tObject({
writableStream: tChannel(['WritableStream']), rootDir: tOptional(tChannel(['WritableStream'])),
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']),
@ -1623,6 +1627,8 @@ scheme.FrameSetInputFilesParams = tObject({
mimeType: tOptional(tString), mimeType: tOptional(tString),
buffer: tBinary, buffer: tBinary,
}))), }))),
localDirectory: tOptional(tString),
directoryStream: tOptional(tChannel(['WritableStream'])),
localPaths: tOptional(tArray(tString)), localPaths: tOptional(tArray(tString)),
streams: tOptional(tArray(tChannel(['WritableStream']))), streams: tOptional(tArray(tChannel(['WritableStream']))),
timeout: tOptional(tNumber), timeout: tOptional(tNumber),
@ -1990,6 +1996,8 @@ scheme.ElementHandleSetInputFilesParams = tObject({
mimeType: tOptional(tString), mimeType: tOptional(tString),
buffer: tBinary, buffer: tBinary,
}))), }))),
localDirectory: tOptional(tString),
directoryStream: tOptional(tChannel(['WritableStream'])),
localPaths: tOptional(tArray(tString)), localPaths: tOptional(tArray(tString)),
streams: tOptional(tArray(tChannel(['WritableStream']))), streams: tOptional(tArray(tChannel(['WritableStream']))),
timeout: tOptional(tNumber), timeout: tOptional(tNumber),

View File

@ -178,13 +178,20 @@ export class BrowserContextDispatcher extends Dispatcher<BrowserContext, channel
return false; return false;
} }
async createTempFile(params: channels.BrowserContextCreateTempFileParams): Promise<channels.BrowserContextCreateTempFileResult> { async createTempFiles(params: channels.BrowserContextCreateTempFilesParams): Promise<channels.BrowserContextCreateTempFilesResult> {
const dir = this._context._browser.options.artifactsDir; const dir = this._context._browser.options.artifactsDir;
const tmpDir = path.join(dir, 'upload-' + createGuid()); const tmpDir = path.join(dir, 'upload-' + createGuid());
await fs.promises.mkdir(tmpDir); const tempDirWithRootName = params.rootDirName ? path.join(tmpDir, path.basename(params.rootDirName)) : tmpDir;
await fs.promises.mkdir(tempDirWithRootName, { recursive: true });
this._context._tempDirs.push(tmpDir); this._context._tempDirs.push(tmpDir);
const file = fs.createWriteStream(path.join(tmpDir, params.name)); return {
return { writableStream: new WritableStreamDispatcher(this, file, params.lastModifiedMs) }; rootDir: params.rootDirName ? new WritableStreamDispatcher(this, tempDirWithRootName) : undefined,
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) {

View File

@ -20,17 +20,19 @@ import * as fs from 'fs';
import { createGuid } from '../../utils'; import { createGuid } from '../../utils';
import type { BrowserContextDispatcher } from './browserContextDispatcher'; import type { BrowserContextDispatcher } from './browserContextDispatcher';
export class WritableStreamDispatcher extends Dispatcher<{ guid: string, stream: fs.WriteStream }, channels.WritableStreamChannel, BrowserContextDispatcher> implements channels.WritableStreamChannel { export class WritableStreamDispatcher extends Dispatcher<{ guid: string, streamOrDirectory: fs.WriteStream | string }, channels.WritableStreamChannel, BrowserContextDispatcher> implements channels.WritableStreamChannel {
_type_WritableStream = true; _type_WritableStream = true;
private _lastModifiedMs: number | undefined; private _lastModifiedMs: number | undefined;
constructor(scope: BrowserContextDispatcher, stream: fs.WriteStream, lastModifiedMs?: number) { constructor(scope: BrowserContextDispatcher, streamOrDirectory: fs.WriteStream | string, lastModifiedMs?: number) {
super(scope, { guid: 'writableStream@' + createGuid(), stream }, 'WritableStream', {}); super(scope, { guid: 'writableStream@' + createGuid(), streamOrDirectory }, 'WritableStream', {});
this._lastModifiedMs = lastModifiedMs; this._lastModifiedMs = lastModifiedMs;
} }
async write(params: channels.WritableStreamWriteParams): Promise<channels.WritableStreamWriteResult> { async write(params: channels.WritableStreamWriteParams): Promise<channels.WritableStreamWriteResult> {
const stream = this._object.stream; if (typeof this._object.streamOrDirectory === 'string')
throw new Error('Cannot write to a directory');
const stream = this._object.streamOrDirectory;
await new Promise<void>((fulfill, reject) => { await new Promise<void>((fulfill, reject) => {
stream.write(params.binary, error => { stream.write(params.binary, error => {
if (error) if (error)
@ -42,13 +44,17 @@ export class WritableStreamDispatcher extends Dispatcher<{ guid: string, stream:
} }
async close() { async close() {
const stream = this._object.stream; if (typeof this._object.streamOrDirectory === 'string')
throw new Error('Cannot close a directory');
const stream = this._object.streamOrDirectory;
await new Promise<void>(fulfill => stream.end(fulfill)); await new Promise<void>(fulfill => stream.end(fulfill));
if (this._lastModifiedMs) if (this._lastModifiedMs)
await fs.promises.utimes(this.path(), new Date(this._lastModifiedMs), new Date(this._lastModifiedMs)); await fs.promises.utimes(this.path(), new Date(this._lastModifiedMs), new Date(this._lastModifiedMs));
} }
path(): string { path(): string {
return this._object.stream.path as string; if (typeof this._object.streamOrDirectory === 'string')
return this._object.streamOrDirectory;
return this._object.streamOrDirectory.path as string;
} }
} }

View File

@ -34,6 +34,7 @@ import { prepareFilesForUpload } from './fileUploadUtils';
export type InputFilesItems = { export type InputFilesItems = {
filePayloads?: types.FilePayload[], filePayloads?: types.FilePayload[],
localPaths?: string[] localPaths?: string[]
localDirectory?: string
}; };
type ActionName = 'click' | 'hover' | 'dblclick' | 'tap' | 'move and up' | 'move and down'; type ActionName = 'click' | 'hover' | 'dblclick' | 'tap' | 'move and up' | 'move and down';
@ -625,29 +626,38 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
} }
async _setInputFiles(progress: Progress, items: InputFilesItems, options: types.NavigatingActionWaitOptions): Promise<'error:notconnected' | 'done'> { async _setInputFiles(progress: Progress, items: InputFilesItems, options: types.NavigatingActionWaitOptions): Promise<'error:notconnected' | 'done'> {
const { filePayloads, localPaths } = items; const { filePayloads, localPaths, localDirectory } = items;
const multiple = filePayloads && filePayloads.length > 1 || localPaths && localPaths.length > 1; const multiple = filePayloads && filePayloads.length > 1 || localPaths && localPaths.length > 1;
const result = await this.evaluateHandleInUtility(([injected, node, multiple]): Element | undefined => { const result = await this.evaluateHandleInUtility(([injected, node, { multiple, directoryUpload }]): Element | undefined => {
const element = injected.retarget(node, 'follow-label'); const element = injected.retarget(node, 'follow-label');
if (!element) if (!element)
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) const inputElement = element as HTMLInputElement;
if (multiple && !inputElement.multiple && !inputElement.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; if (directoryUpload && !inputElement.webkitdirectory)
}, multiple); throw injected.createStacklessError('File input does not support directories, pass individual files instead');
return inputElement;
}, { multiple, directoryUpload: !!localDirectory });
if (result === 'error:notconnected' || !result.asElement()) if (result === 'error:notconnected' || !result.asElement())
return 'error:notconnected'; return 'error:notconnected';
const retargeted = result.asElement() as ElementHandle<HTMLInputElement>; const retargeted = result.asElement() as ElementHandle<HTMLInputElement>;
await progress.beforeInputAction(this); await progress.beforeInputAction(this);
await this._page._frameManager.waitForSignalsCreatedBy(progress, options.noWaitAfter, async () => { await this._page._frameManager.waitForSignalsCreatedBy(progress, options.noWaitAfter, async () => {
progress.throwIfAborted(); // Avoid action that has side-effects. progress.throwIfAborted(); // Avoid action that has side-effects.
if (localPaths) { if (localPaths || localDirectory) {
await Promise.all(localPaths.map(localPath => ( const localPathsOrDirectory = localDirectory ? [localDirectory] : localPaths!;
await Promise.all((localPathsOrDirectory).map(localPath => (
fs.promises.access(localPath, fs.constants.F_OK) fs.promises.access(localPath, fs.constants.F_OK)
))); )));
await this._page._delegate.setInputFilePaths(retargeted, localPaths); // Browsers traverse the given directory asynchronously and we want to ensure all files are uploaded.
const waitForInputEvent = localDirectory ? this.evaluate(node => new Promise<any>(fulfill => {
node.addEventListener('input', fulfill, { once: true });
})).catch(() => {}) : Promise.resolve();
await this._page._delegate.setInputFilePaths(retargeted, localPathsOrDirectory);
await waitForInputEvent;
} else { } else {
await this._page._delegate.setInputFiles(retargeted, filePayloads!); await this._page._delegate.setInputFiles(retargeted, filePayloads!);
} }

View File

@ -30,14 +30,17 @@ async function filesExceedUploadLimit(files: string[]) {
} }
export async function prepareFilesForUpload(frame: Frame, params: channels.ElementHandleSetInputFilesParams): Promise<InputFilesItems> { export async function prepareFilesForUpload(frame: Frame, params: channels.ElementHandleSetInputFilesParams): Promise<InputFilesItems> {
const { payloads, streams } = params; const { payloads, streams, directoryStream } = params;
let { localPaths } = params; let { localPaths, localDirectory } = params;
if ([payloads, localPaths, streams].filter(Boolean).length !== 1) if ([payloads, localPaths, localDirectory, streams, directoryStream].filter(Boolean).length !== 1)
throw new Error('Exactly one of payloads, localPaths and streams must be provided'); throw new Error('Exactly one of payloads, localPaths and streams must be provided');
if (streams) if (streams)
localPaths = streams.map(c => (c as WritableStreamDispatcher).path()); localPaths = streams.map(c => (c as WritableStreamDispatcher).path());
if (directoryStream)
localDirectory = (directoryStream as WritableStreamDispatcher).path();
if (localPaths) { if (localPaths) {
for (const p of localPaths) for (const p of localPaths)
assert(path.isAbsolute(p) && path.resolve(p) === p, 'Paths provided to localPaths must be absolute and fully resolved.'); assert(path.isAbsolute(p) && path.resolve(p) === p, 'Paths provided to localPaths must be absolute and fully resolved.');
@ -73,5 +76,5 @@ export async function prepareFilesForUpload(frame: Frame, params: channels.Eleme
lastModifiedMs: payload.lastModifiedMs lastModifiedMs: payload.lastModifiedMs
})); }));
return { localPaths, filePayloads }; return { localPaths, localDirectory, filePayloads };
} }

View File

@ -226,12 +226,12 @@ export class WKPage implements PageDelegate {
} }
if (this._page.fileChooserIntercepted()) if (this._page.fileChooserIntercepted())
promises.push(session.send('Page.setInterceptFileChooserDialog', { enabled: true })); promises.push(session.send('Page.setInterceptFileChooserDialog', { enabled: true }));
promises.push(session.send('Page.overrideSetting', { setting: 'DeviceOrientationEventEnabled' as any, value: contextOptions.isMobile })); promises.push(session.send('Page.overrideSetting', { setting: 'DeviceOrientationEventEnabled', value: contextOptions.isMobile }));
promises.push(session.send('Page.overrideSetting', { setting: 'FullScreenEnabled' as any, value: !contextOptions.isMobile })); promises.push(session.send('Page.overrideSetting', { setting: 'FullScreenEnabled', value: !contextOptions.isMobile }));
promises.push(session.send('Page.overrideSetting', { setting: 'NotificationsEnabled' as any, value: !contextOptions.isMobile })); promises.push(session.send('Page.overrideSetting', { setting: 'NotificationsEnabled', value: !contextOptions.isMobile }));
promises.push(session.send('Page.overrideSetting', { setting: 'PointerLockEnabled' as any, value: !contextOptions.isMobile })); promises.push(session.send('Page.overrideSetting', { setting: 'PointerLockEnabled', value: !contextOptions.isMobile }));
promises.push(session.send('Page.overrideSetting', { setting: 'InputTypeMonthEnabled' as any, value: contextOptions.isMobile })); promises.push(session.send('Page.overrideSetting', { setting: 'InputTypeMonthEnabled', value: contextOptions.isMobile }));
promises.push(session.send('Page.overrideSetting', { setting: 'InputTypeWeekEnabled' as any, value: contextOptions.isMobile })); promises.push(session.send('Page.overrideSetting', { setting: 'InputTypeWeekEnabled', value: contextOptions.isMobile }));
await Promise.all(promises); await Promise.all(promises);
} }

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. For inputs
* with a `[webkitdirectory]` attribute, only a single directory path is supported.
* *
* 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
@ -10580,7 +10581,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. For inputs
* with a `[webkitdirectory]` attribute, only a single directory path is supported.
* *
* 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
@ -12787,7 +12789,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>`. For inputs with a `[webkitdirectory]` attribute, only a
* single directory path is supported.
* *
* **Usage** * **Usage**
* *

View File

@ -1458,7 +1458,7 @@ export interface BrowserContextChannel extends BrowserContextEventTarget, EventT
newCDPSession(params: BrowserContextNewCDPSessionParams, metadata?: CallMetadata): Promise<BrowserContextNewCDPSessionResult>; newCDPSession(params: BrowserContextNewCDPSessionParams, metadata?: CallMetadata): Promise<BrowserContextNewCDPSessionResult>;
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>; createTempFiles(params: BrowserContextCreateTempFilesParams, metadata?: CallMetadata): Promise<BrowserContextCreateTempFilesResult>;
updateSubscription(params: BrowserContextUpdateSubscriptionParams, metadata?: CallMetadata): Promise<BrowserContextUpdateSubscriptionResult>; updateSubscription(params: BrowserContextUpdateSubscriptionParams, metadata?: CallMetadata): Promise<BrowserContextUpdateSubscriptionResult>;
clockFastForward(params: BrowserContextClockFastForwardParams, metadata?: CallMetadata): Promise<BrowserContextClockFastForwardResult>; clockFastForward(params: BrowserContextClockFastForwardParams, metadata?: CallMetadata): Promise<BrowserContextClockFastForwardResult>;
clockInstall(params: BrowserContextClockInstallParams, metadata?: CallMetadata): Promise<BrowserContextClockInstallResult>; clockInstall(params: BrowserContextClockInstallParams, metadata?: CallMetadata): Promise<BrowserContextClockInstallResult>;
@ -1737,15 +1737,19 @@ export type BrowserContextHarExportOptions = {
export type BrowserContextHarExportResult = { export type BrowserContextHarExportResult = {
artifact: ArtifactChannel, artifact: ArtifactChannel,
}; };
export type BrowserContextCreateTempFileParams = { export type BrowserContextCreateTempFilesParams = {
name: string, rootDirName?: string,
lastModifiedMs?: number, items: {
name: string,
lastModifiedMs?: number,
}[],
}; };
export type BrowserContextCreateTempFileOptions = { export type BrowserContextCreateTempFilesOptions = {
lastModifiedMs?: number, rootDirName?: string,
}; };
export type BrowserContextCreateTempFileResult = { export type BrowserContextCreateTempFilesResult = {
writableStream: WritableStreamChannel, rootDir?: WritableStreamChannel,
writableStreams: WritableStreamChannel[],
}; };
export type BrowserContextUpdateSubscriptionParams = { export type BrowserContextUpdateSubscriptionParams = {
event: 'console' | 'dialog' | 'request' | 'response' | 'requestFinished' | 'requestFailed', event: 'console' | 'dialog' | 'request' | 'response' | 'requestFinished' | 'requestFailed',
@ -2918,6 +2922,8 @@ export type FrameSetInputFilesParams = {
mimeType?: string, mimeType?: string,
buffer: Binary, buffer: Binary,
}[], }[],
localDirectory?: string,
directoryStream?: WritableStreamChannel,
localPaths?: string[], localPaths?: string[],
streams?: WritableStreamChannel[], streams?: WritableStreamChannel[],
timeout?: number, timeout?: number,
@ -2930,6 +2936,8 @@ export type FrameSetInputFilesOptions = {
mimeType?: string, mimeType?: string,
buffer: Binary, buffer: Binary,
}[], }[],
localDirectory?: string,
directoryStream?: WritableStreamChannel,
localPaths?: string[], localPaths?: string[],
streams?: WritableStreamChannel[], streams?: WritableStreamChannel[],
timeout?: number, timeout?: number,
@ -3542,6 +3550,8 @@ export type ElementHandleSetInputFilesParams = {
mimeType?: string, mimeType?: string,
buffer: Binary, buffer: Binary,
}[], }[],
localDirectory?: string,
directoryStream?: WritableStreamChannel,
localPaths?: string[], localPaths?: string[],
streams?: WritableStreamChannel[], streams?: WritableStreamChannel[],
timeout?: number, timeout?: number,
@ -3553,6 +3563,8 @@ export type ElementHandleSetInputFilesOptions = {
mimeType?: string, mimeType?: string,
buffer: Binary, buffer: Binary,
}[], }[],
localDirectory?: string,
directoryStream?: WritableStreamChannel,
localPaths?: string[], localPaths?: string[],
streams?: WritableStreamChannel[], streams?: WritableStreamChannel[],
timeout?: number, timeout?: number,

View File

@ -1184,12 +1184,21 @@ BrowserContext:
returns: returns:
artifact: Artifact artifact: Artifact
createTempFile: createTempFiles:
parameters: parameters:
name: string rootDirName: string?
lastModifiedMs: number? items:
type: array
items:
type: object
properties:
name: string
lastModifiedMs: number?
returns: returns:
writableStream: WritableStream rootDir: WritableStream?
writableStreams:
type: array
items: WritableStream
updateSubscription: updateSubscription:
parameters: parameters:
@ -2184,6 +2193,8 @@ Frame:
name: string name: string
mimeType: string? mimeType: string?
buffer: binary buffer: binary
localDirectory: string?
directoryStream: WritableStream?
localPaths: localPaths:
type: array? type: array?
items: string items: string
@ -2744,6 +2755,8 @@ ElementHandle:
name: string name: string
mimeType: string? mimeType: string?
buffer: binary buffer: binary
localDirectory: string?
directoryStream: WritableStream?
localPaths: localPaths:
type: array? type: array?
items: string items: string

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

@ -55,6 +55,7 @@ config.projects.push({
name: 'electron-api', name: 'electron-api',
use: { use: {
browserName: 'chromium', browserName: 'chromium',
headless: false,
}, },
testDir: path.join(testDir, 'electron'), testDir: path.join(testDir, 'electron'),
metadata, metadata,
@ -66,6 +67,7 @@ config.projects.push({
snapshotPathTemplate: '{testDir}/{testFileDir}/{testFileName}-snapshots/{arg}-chromium{ext}', snapshotPathTemplate: '{testDir}/{testFileDir}/{testFileName}-snapshots/{arg}-chromium{ext}',
use: { use: {
browserName: 'chromium', browserName: 'chromium',
headless: false,
}, },
testDir: path.join(testDir, 'page'), testDir: path.join(testDir, 'page'),
metadata, metadata,

View File

@ -16,7 +16,7 @@
*/ */
import { test as it, expect } from './pageTest'; import { test as it, expect } from './pageTest';
import { attachFrame } from '../config/utils'; import { attachFrame, chromiumVersionLessThan } from '../config/utils';
import path from 'path'; import path from 'path';
import fs from 'fs'; import fs from 'fs';
@ -37,6 +37,77 @@ it('should upload the file', async ({ page, server, asset }) => {
}, input)).toBe('contents of the file'); }, input)).toBe('contents of the file');
}); });
it('should upload a folder', async ({ page, server, browserName, headless, browserVersion }) => {
await page.goto(server.PREFIX + '/input/folderupload.html');
const input = await page.$('input');
const dir = path.join(it.info().outputDir, 'file-upload-test');
{
await fs.promises.mkdir(dir, { recursive: true });
await fs.promises.writeFile(path.join(dir, 'file1.txt'), 'file1 content');
await fs.promises.writeFile(path.join(dir, 'file2'), 'file2 content');
await fs.promises.mkdir(path.join(dir, 'sub-dir'));
await fs.promises.writeFile(path.join(dir, 'sub-dir', 'really.txt'), 'sub-dir file content');
}
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 && !process.env.PLAYWRIGHT_CHROMIUM_USE_HEADLESS_NEW && chromiumVersionLessThan(browserVersion, '127.0.6533.0')) ? [] : ['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 folder and throw for multiple directories', async ({ page, server, browserName, headless, browserMajorVersion }) => {
await page.goto(server.PREFIX + '/input/folderupload.html');
const input = await page.$('input');
const dir = path.join(it.info().outputDir, 'file-upload-test');
{
await fs.promises.mkdir(path.join(dir, 'folder1'), { recursive: true });
await fs.promises.writeFile(path.join(dir, 'folder1', 'file1.txt'), 'file1 content');
await fs.promises.mkdir(path.join(dir, 'folder2'), { recursive: true });
await fs.promises.writeFile(path.join(dir, 'folder2', 'file2.txt'), 'file2 content');
}
await expect(input.setInputFiles([
path.join(dir, 'folder1'),
path.join(dir, 'folder2'),
])).rejects.toThrow('Multiple directories are not supported');
});
it('should throw if a directory and files are passed', async ({ page, server, browserName, headless, browserMajorVersion }) => {
await page.goto(server.PREFIX + '/input/folderupload.html');
const input = await page.$('input');
const dir = path.join(it.info().outputDir, 'file-upload-test');
{
await fs.promises.mkdir(path.join(dir, 'folder1'), { recursive: true });
await fs.promises.writeFile(path.join(dir, 'folder1', 'file1.txt'), 'file1 content');
}
await expect(input.setInputFiles([
path.join(dir, 'folder1'),
path.join(dir, 'folder1', 'file1.txt'),
])).rejects.toThrow('File paths must be all files or a single directory');
});
it('should throw when uploading a folder in a normal file upload input', async ({ page, server, browserName, headless, browserMajorVersion }) => {
await page.goto(server.PREFIX + '/input/fileupload.html');
const input = await page.$('input');
const dir = path.join(it.info().outputDir, 'file-upload-test');
{
await fs.promises.mkdir(path.join(dir), { recursive: true });
await fs.promises.writeFile(path.join(dir, 'file1.txt'), 'file1 content');
}
await expect(input.setInputFiles(dir)).rejects.toThrow('File input does not support directories, pass individual files instead');
});
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');

View File

@ -56,6 +56,7 @@ config.projects.push({
snapshotPathTemplate: '{testDir}/{testFileDir}/{testFileName}-snapshots/{arg}-chromium{ext}', snapshotPathTemplate: '{testDir}/{testFileDir}/{testFileName}-snapshots/{arg}-chromium{ext}',
use: { use: {
browserName: 'chromium', browserName: 'chromium',
headless: false,
}, },
testDir: path.join(testDir, 'page'), testDir: path.join(testDir, 'page'),
metadata, metadata,