mirror of
https://github.com/microsoft/playwright.git
synced 2025-01-05 19:04:43 +03:00
feat: allow folder uploads (#31165)
This commit is contained in:
parent
751a41f9ee
commit
dcf4e4e054
@ -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.
|
||||
|
@ -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**
|
||||
|
||||
|
@ -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.
|
||||
|
@ -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[];
|
||||
|
@ -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),
|
||||
|
@ -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) {
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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!);
|
||||
}
|
||||
|
@ -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 };
|
||||
}
|
@ -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);
|
||||
}
|
||||
|
||||
|
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. 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**
|
||||
*
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
|
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>
|
@ -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,
|
||||
|
@ -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');
|
||||
|
@ -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,
|
||||
|
Loading…
Reference in New Issue
Block a user