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
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
[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
Upload file or multiple files into `<input type=file>`.
For inputs with a `[webkitdirectory]` attribute, only a single directory path is supported.
**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
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
[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[] };
}
type SetInputFilesFiles = Pick<channels.ElementHandleSetInputFilesParams, 'payloads' | 'localPaths' | 'streams'>;
type SetInputFilesFiles = Pick<channels.ElementHandleSetInputFilesParams, 'payloads' | 'localPaths' | 'localDirectory' | 'streams' | 'directoryStream'>;
function filePayloadExceedsSizeLimit(payloads: FilePayload[]) {
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> {
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'))
throw new Error('File paths cannot be mixed with buffers');
const [localPaths, localDirectory] = await resolvePathsAndDirectoryForInputFiles(items as string[]);
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 };
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 { writableStreams, rootDir } = await context._wrapApiCall(async () => context._channel.createTempFiles({
rootDirName: localDirectory ? path.basename(localDirectory as string) : undefined,
items: await Promise.all(files.map(async file => {
const lastModifiedMs = (await fs.promises.stat(file)).mtimeMs;
return {
name: localDirectory ? path.relative(localDirectory as string, file) : path.basename(file),
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[];

View File

@ -951,12 +951,16 @@ scheme.BrowserContextHarExportParams = tObject({
scheme.BrowserContextHarExportResult = tObject({
artifact: tChannel(['Artifact']),
});
scheme.BrowserContextCreateTempFileParams = tObject({
name: tString,
lastModifiedMs: tOptional(tNumber),
scheme.BrowserContextCreateTempFilesParams = tObject({
rootDirName: tOptional(tString),
items: tArray(tObject({
name: tString,
lastModifiedMs: tOptional(tNumber),
})),
});
scheme.BrowserContextCreateTempFileResult = tObject({
writableStream: tChannel(['WritableStream']),
scheme.BrowserContextCreateTempFilesResult = tObject({
rootDir: tOptional(tChannel(['WritableStream'])),
writableStreams: tArray(tChannel(['WritableStream'])),
});
scheme.BrowserContextUpdateSubscriptionParams = tObject({
event: tEnum(['console', 'dialog', 'request', 'response', 'requestFinished', 'requestFailed']),
@ -1623,6 +1627,8 @@ scheme.FrameSetInputFilesParams = tObject({
mimeType: tOptional(tString),
buffer: tBinary,
}))),
localDirectory: tOptional(tString),
directoryStream: tOptional(tChannel(['WritableStream'])),
localPaths: tOptional(tArray(tString)),
streams: tOptional(tArray(tChannel(['WritableStream']))),
timeout: tOptional(tNumber),
@ -1990,6 +1996,8 @@ scheme.ElementHandleSetInputFilesParams = tObject({
mimeType: tOptional(tString),
buffer: tBinary,
}))),
localDirectory: tOptional(tString),
directoryStream: tOptional(tChannel(['WritableStream'])),
localPaths: tOptional(tArray(tString)),
streams: tOptional(tArray(tChannel(['WritableStream']))),
timeout: tOptional(tNumber),

View File

@ -178,13 +178,20 @@ export class BrowserContextDispatcher extends Dispatcher<BrowserContext, channel
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 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);
const file = fs.createWriteStream(path.join(tmpDir, params.name));
return { writableStream: new WritableStreamDispatcher(this, file, params.lastModifiedMs) };
return {
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) {

View File

@ -20,17 +20,19 @@ import * as fs from 'fs';
import { createGuid } from '../../utils';
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;
private _lastModifiedMs: number | undefined;
constructor(scope: BrowserContextDispatcher, stream: fs.WriteStream, lastModifiedMs?: number) {
super(scope, { guid: 'writableStream@' + createGuid(), stream }, 'WritableStream', {});
constructor(scope: BrowserContextDispatcher, streamOrDirectory: fs.WriteStream | string, lastModifiedMs?: number) {
super(scope, { guid: 'writableStream@' + createGuid(), streamOrDirectory }, 'WritableStream', {});
this._lastModifiedMs = lastModifiedMs;
}
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) => {
stream.write(params.binary, error => {
if (error)
@ -42,13 +44,17 @@ export class WritableStreamDispatcher extends Dispatcher<{ guid: string, stream:
}
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));
if (this._lastModifiedMs)
await fs.promises.utimes(this.path(), new Date(this._lastModifiedMs), new Date(this._lastModifiedMs));
}
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 = {
filePayloads?: types.FilePayload[],
localPaths?: string[]
localDirectory?: string
};
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'> {
const { filePayloads, localPaths } = items;
const { filePayloads, localPaths, localDirectory } = items;
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');
if (!element)
return;
if (element.tagName !== 'INPUT')
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');
return element;
}, multiple);
if (directoryUpload && !inputElement.webkitdirectory)
throw injected.createStacklessError('File input does not support directories, pass individual files instead');
return inputElement;
}, { multiple, directoryUpload: !!localDirectory });
if (result === 'error:notconnected' || !result.asElement())
return 'error:notconnected';
const retargeted = result.asElement() as ElementHandle<HTMLInputElement>;
await progress.beforeInputAction(this);
await this._page._frameManager.waitForSignalsCreatedBy(progress, options.noWaitAfter, async () => {
progress.throwIfAborted(); // Avoid action that has side-effects.
if (localPaths) {
await Promise.all(localPaths.map(localPath => (
if (localPaths || localDirectory) {
const localPathsOrDirectory = localDirectory ? [localDirectory] : localPaths!;
await Promise.all((localPathsOrDirectory).map(localPath => (
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 {
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> {
const { payloads, streams } = params;
let { localPaths } = params;
const { payloads, streams, directoryStream } = 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');
if (streams)
localPaths = streams.map(c => (c as WritableStreamDispatcher).path());
if (directoryStream)
localDirectory = (directoryStream as WritableStreamDispatcher).path();
if (localPaths) {
for (const p of localPaths)
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
}));
return { localPaths, filePayloads };
return { localPaths, localDirectory, filePayloads };
}

View File

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

View File

@ -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. For inputs
* with a `[webkitdirectory]` attribute, only a single directory path is supported.
*
* 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
@ -10580,7 +10581,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. For inputs
* with a `[webkitdirectory]` attribute, only a single directory path is supported.
*
* 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
@ -12787,7 +12789,8 @@ export interface Locator {
}): 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**
*

View File

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

View File

@ -1184,12 +1184,21 @@ BrowserContext:
returns:
artifact: Artifact
createTempFile:
createTempFiles:
parameters:
name: string
lastModifiedMs: number?
rootDirName: string?
items:
type: array
items:
type: object
properties:
name: string
lastModifiedMs: number?
returns:
writableStream: WritableStream
rootDir: WritableStream?
writableStreams:
type: array
items: WritableStream
updateSubscription:
parameters:
@ -2184,6 +2193,8 @@ Frame:
name: string
mimeType: string?
buffer: binary
localDirectory: string?
directoryStream: WritableStream?
localPaths:
type: array?
items: string
@ -2744,6 +2755,8 @@ ElementHandle:
name: string
mimeType: string?
buffer: binary
localDirectory: string?
directoryStream: WritableStream?
localPaths:
type: array?
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',
use: {
browserName: 'chromium',
headless: false,
},
testDir: path.join(testDir, 'electron'),
metadata,
@ -66,6 +67,7 @@ config.projects.push({
snapshotPathTemplate: '{testDir}/{testFileDir}/{testFileName}-snapshots/{arg}-chromium{ext}',
use: {
browserName: 'chromium',
headless: false,
},
testDir: path.join(testDir, 'page'),
metadata,

View File

@ -16,7 +16,7 @@
*/
import { test as it, expect } from './pageTest';
import { attachFrame } from '../config/utils';
import { attachFrame, chromiumVersionLessThan } from '../config/utils';
import path from 'path';
import fs from 'fs';
@ -37,6 +37,77 @@ it('should upload the file', async ({ page, server, asset }) => {
}, 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.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/29923' });
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}',
use: {
browserName: 'chromium',
headless: false,
},
testDir: path.join(testDir, 'page'),
metadata,