feat(chromium): large file uploads (#12860)

This commit is contained in:
Yury Semikhatsky 2022-03-18 09:00:52 -07:00 committed by GitHub
parent c721c5c3b1
commit a8d80621b2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 498 additions and 23 deletions

View file

@ -32,6 +32,7 @@ import { Playwright } from './playwright';
import { Electron, ElectronApplication } from './electron';
import * as channels from '../protocol/channels';
import { Stream } from './stream';
import { WritableStream } from './writableStream';
import { debugLogger } from '../utils/debugLogger';
import { SelectorsOwner } from './selectors';
import { Android, AndroidSocket, AndroidDevice } from './android';
@ -269,6 +270,9 @@ export class Connection extends EventEmitter {
case 'Worker':
result = new Worker(parent, type, guid, initializer);
break;
case 'WritableStream':
result = new WritableStream(parent, type, guid, initializer);
break;
default:
throw new Error('Missing type ' + type);
}

View file

@ -26,6 +26,13 @@ import path from 'path';
import { assert, isString, mkdirIfNeeded } from '../utils/utils';
import * as api from '../../types/types';
import * as structs from '../../types/structs';
import { BrowserContext } from './browserContext';
import { WritableStream } from './writableStream';
import { pipeline } from 'stream';
import { promisify } from 'util';
import { debugLogger } from '../utils/debugLogger';
const pipelineAsync = promisify(pipeline);
export class ElementHandle<T extends Node = Node> extends JSHandle<T> implements api.ElementHandle {
readonly _elementChannel: channels.ElementHandleChannel;
@ -139,7 +146,16 @@ export class ElementHandle<T extends Node = Node> extends JSHandle<T> implements
}
async setInputFiles(files: string | FilePayload | string[] | FilePayload[], options: channels.ElementHandleSetInputFilesOptions = {}) {
await this._elementChannel.setInputFiles({ files: await convertInputFiles(files), ...options });
const frame = await this.ownerFrame();
if (!frame)
throw new Error('Cannot set input files to detached element');
const converted = await convertInputFiles(files, frame.page().context());
if (converted.files) {
await this._elementChannel.setInputFiles({ files: converted.files, ...options });
} else {
debugLogger.log('api', 'switching to large files mode');
await this._elementChannel.setInputFilePaths({ ...converted, ...options });
}
}
async focus(): Promise<void> {
@ -241,8 +257,35 @@ export function convertSelectOptionValues(values: string | api.ElementHandle | S
}
type SetInputFilesFiles = channels.ElementHandleSetInputFilesParams['files'];
export async function convertInputFiles(files: string | FilePayload | string[] | FilePayload[]): Promise<SetInputFilesFiles> {
const items: (string | FilePayload)[] = Array.isArray(files) ? files : [ files ];
type InputFilesList = {
files?: SetInputFilesFiles;
localPaths?: string[];
streams?: channels.WritableStreamChannel[];
};
export async function convertInputFiles(files: string | FilePayload | string[] | FilePayload[], context: BrowserContext): Promise<InputFilesList> {
const items: (string | FilePayload)[] = Array.isArray(files) ? files.slice() : [ files ];
const sizeLimit = 50 * 1024 * 1024;
const hasLargeBuffer = items.find(item => typeof item === 'object' && item.buffer && item.buffer.byteLength > sizeLimit);
if (hasLargeBuffer)
throw new Error('Cannot set buffer larger than 50Mb, please write it to a file and pass its path instead.');
const stats = await Promise.all(items.filter(isString).map(item => fs.promises.stat(item as string)));
const hasLargeFile = !!stats.find(s => s.size > sizeLimit);
if (hasLargeFile) {
if (context._connection.isRemote()) {
const streams: channels.WritableStreamChannel[] = await Promise.all(items.map(async item => {
assert(isString(item));
const { writableStream: stream } = await context._channel.createTempFile({ name: path.basename(item) });
const writable = WritableStream.from(stream);
await pipelineAsync(fs.createReadStream(item), writable.stream());
return stream;
}));
return { streams };
}
return { localPaths: items as string[] };
}
const filePayloads: SetInputFilesFiles = await Promise.all(items.map(async item => {
if (typeof item === 'string') {
return {
@ -257,7 +300,7 @@ export async function convertInputFiles(files: string | FilePayload | string[] |
};
}
}));
return filePayloads;
return { files: filePayloads };
}
export function determineScreenshotType(options: { path?: string, type?: 'png' | 'jpeg' }): 'png' | 'jpeg' | undefined {

View file

@ -31,6 +31,7 @@ import { LifecycleEvent, URLMatch, SelectOption, SelectOptionOptions, FilePayloa
import { urlMatches } from './clientHelper';
import * as api from '../../types/types';
import * as structs from '../../types/structs';
import { debugLogger } from '../utils/debugLogger';
export type WaitForNavigationOptions = {
timeout?: number,
@ -355,7 +356,13 @@ export class Frame extends ChannelOwner<channels.FrameChannel> implements api.Fr
}
async setInputFiles(selector: string, files: string | FilePayload | string[] | FilePayload[], options: channels.FrameSetInputFilesOptions = {}): Promise<void> {
await this._channel.setInputFiles({ selector, files: await convertInputFiles(files), ...options });
const converted = await convertInputFiles(files, this.page().context());
if (converted.files) {
await this._channel.setInputFiles({ selector, files: converted.files, ...options });
} else {
debugLogger.log('api', 'switching to large files mode');
await this._channel.setInputFilePaths({ selector, ...converted, ...options });
}
}
async type(selector: string, text: string, options: channels.FrameTypeOptions = {}) {

View file

@ -0,0 +1,53 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Writable } from 'stream';
import * as channels from '../protocol/channels';
import { ChannelOwner } from './channelOwner';
export class WritableStream extends ChannelOwner<channels.WritableStreamChannel> {
static from(Stream: channels.WritableStreamChannel): WritableStream {
return (Stream as any)._object;
}
constructor(parent: ChannelOwner, type: string, guid: string, initializer: channels.WritableStreamInitializer) {
super(parent, type, guid, initializer);
}
stream(): Writable {
return new WritableStreamImpl(this._channel);
}
}
class WritableStreamImpl extends Writable {
private _channel: channels.WritableStreamChannel;
constructor(channel: channels.WritableStreamChannel) {
super();
this._channel = channel;
}
override async _write(chunk: any, encoding: BufferEncoding, callback: (error?: Error | null) => void) {
const error = await this._channel.write({ binary: chunk.toString('base64') }).catch(e => e);
callback(error || null);
}
override async _final(callback: (error?: Error | null) => void) {
// Stream might be destroyed after the connection was closed.
const error = await this._channel.close().catch(e => e);
callback(error || null);
}
}

View file

@ -28,6 +28,10 @@ import { ArtifactDispatcher } from './artifactDispatcher';
import { Artifact } from '../server/artifact';
import { Request, Response } from '../server/network';
import { TracingDispatcher } from './tracingDispatcher';
import * as fs from 'fs';
import * as path from 'path';
import { createGuid } from '../utils/utils';
import { WritableStreamDispatcher } from './writableStreamDispatcher';
export class BrowserContextDispatcher extends Dispatcher<BrowserContext, channels.BrowserContextChannel> implements channels.BrowserContextChannel {
_type_EventTarget = true;
@ -96,6 +100,15 @@ export class BrowserContextDispatcher extends Dispatcher<BrowserContext, channel
}));
}
async createTempFile(params: channels.BrowserContextCreateTempFileParams, metadata?: channels.Metadata): Promise<channels.BrowserContextCreateTempFileResult> {
const dir = this._context._browser.options.artifactsDir;
const tmpDir = path.join(dir, 'upload-' + createGuid());
await fs.promises.mkdir(tmpDir);
this._context._tempDirs.push(tmpDir);
const file = fs.createWriteStream(path.join(tmpDir, params.name));
return { writableStream: new WritableStreamDispatcher(this._scope, file) };
}
async setDefaultNavigationTimeoutNoReply(params: channels.BrowserContextSetDefaultNavigationTimeoutNoReplyParams) {
this._context.setDefaultNavigationTimeout(params.timeout);
}

View file

@ -22,6 +22,7 @@ import { DispatcherScope, existingDispatcher, lookupNullableDispatcher } from '.
import { JSHandleDispatcher, serializeResult, parseArgument } from './jsHandleDispatcher';
import { FrameDispatcher } from './frameDispatcher';
import { CallMetadata } from '../server/instrumentation';
import { WritableStreamDispatcher } from './writableStreamDispatcher';
export class ElementHandleDispatcher extends JSHandleDispatcher implements channels.ElementHandleChannel {
_type_ElementHandle = true;
@ -143,7 +144,17 @@ export class ElementHandleDispatcher extends JSHandleDispatcher implements chann
}
async setInputFiles(params: channels.ElementHandleSetInputFilesParams, metadata: CallMetadata): Promise<void> {
return await this._elementHandle.setInputFiles(metadata, params.files, params);
return await this._elementHandle.setInputFiles(metadata, { files: params.files }, params);
}
async setInputFilePaths(params: channels.ElementHandleSetInputFilePathsParams, metadata: CallMetadata): Promise<void> {
let { localPaths } = params;
if (!localPaths) {
if (!params.streams)
throw new Error('Neither localPaths nor streams is specified');
localPaths = params.streams.map(c => (c as WritableStreamDispatcher).path());
}
return await this._elementHandle.setInputFiles(metadata, { localPaths }, params);
}
async focus(params: channels.ElementHandleFocusParams, metadata: CallMetadata): Promise<void> {

View file

@ -21,6 +21,7 @@ import { ElementHandleDispatcher } from './elementHandlerDispatcher';
import { parseArgument, serializeResult } from './jsHandleDispatcher';
import { ResponseDispatcher, RequestDispatcher } from './networkDispatchers';
import { CallMetadata } from '../server/instrumentation';
import { WritableStreamDispatcher } from './writableStreamDispatcher';
export class FrameDispatcher extends Dispatcher<Frame, channels.FrameChannel> implements channels.FrameChannel {
_type_Frame = true;
@ -200,8 +201,18 @@ export class FrameDispatcher extends Dispatcher<Frame, channels.FrameChannel> im
return { values: await this._frame.selectOption(metadata, params.selector, elements, params.options || [], params) };
}
async setInputFiles(params: channels.FrameSetInputFilesParams, metadata: CallMetadata): Promise<void> {
return await this._frame.setInputFiles(metadata, params.selector, params.files, params);
async setInputFiles(params: channels.FrameSetInputFilesParams, metadata: CallMetadata): Promise<channels.FrameSetInputFilesResult> {
return await this._frame.setInputFiles(metadata, params.selector, { files: params.files }, params);
}
async setInputFilePaths(params: channels.FrameSetInputFilePathsParams, metadata: CallMetadata): Promise<void> {
let { localPaths } = params;
if (!localPaths) {
if (!params.streams)
throw new Error('Neither localPaths nor streams is specified');
localPaths = params.streams.map(c => (c as WritableStreamDispatcher).path());
}
return await this._frame.setInputFiles(metadata, params.selector, { localPaths }, params);
}
async type(params: channels.FrameTypeParams, metadata: CallMetadata): Promise<void> {

View file

@ -0,0 +1,48 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the 'License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import * as channels from '../protocol/channels';
import { Dispatcher, DispatcherScope } from './dispatcher';
import * as fs from 'fs';
import { createGuid } from '../utils/utils';
export class WritableStreamDispatcher extends Dispatcher<{ guid: string, stream: fs.WriteStream }, channels.WritableStreamChannel> implements channels.WritableStreamChannel {
_type_WritableStream = true;
constructor(scope: DispatcherScope, stream: fs.WriteStream) {
super(scope, { guid: 'writableStream@' + createGuid(), stream }, 'WritableStream', {});
}
async write(params: channels.WritableStreamWriteParams): Promise<channels.WritableStreamWriteResult> {
const stream = this._object.stream;
await new Promise<void>((fulfill, reject) => {
stream.write(Buffer.from(params.binary, 'base64'), error => {
if (error)
reject(error);
else
fulfill();
});
});
}
async close() {
const stream = this._object.stream;
await new Promise<void>(fulfill => stream.end(fulfill));
}
path(): string {
return this._object.stream.path as string;
}
}

View file

@ -30,6 +30,7 @@ export type InitializerTraits<T> =
T extends ElectronApplicationChannel ? ElectronApplicationInitializer :
T extends ElectronChannel ? ElectronInitializer :
T extends CDPSessionChannel ? CDPSessionInitializer :
T extends WritableStreamChannel ? WritableStreamInitializer :
T extends StreamChannel ? StreamInitializer :
T extends ArtifactChannel ? ArtifactInitializer :
T extends TracingChannel ? TracingInitializer :
@ -66,6 +67,7 @@ export type EventsTraits<T> =
T extends ElectronApplicationChannel ? ElectronApplicationEvents :
T extends ElectronChannel ? ElectronEvents :
T extends CDPSessionChannel ? CDPSessionEvents :
T extends WritableStreamChannel ? WritableStreamEvents :
T extends StreamChannel ? StreamEvents :
T extends ArtifactChannel ? ArtifactEvents :
T extends TracingChannel ? TracingEvents :
@ -102,6 +104,7 @@ export type EventTargetTraits<T> =
T extends ElectronApplicationChannel ? ElectronApplicationEventTarget :
T extends ElectronChannel ? ElectronEventTarget :
T extends CDPSessionChannel ? CDPSessionEventTarget :
T extends WritableStreamChannel ? WritableStreamEventTarget :
T extends StreamChannel ? StreamEventTarget :
T extends ArtifactChannel ? ArtifactEventTarget :
T extends TracingChannel ? TracingEventTarget :
@ -1072,6 +1075,7 @@ export interface BrowserContextChannel extends BrowserContextEventTarget, EventT
recorderSupplementEnable(params: BrowserContextRecorderSupplementEnableParams, metadata?: Metadata): Promise<BrowserContextRecorderSupplementEnableResult>;
newCDPSession(params: BrowserContextNewCDPSessionParams, metadata?: Metadata): Promise<BrowserContextNewCDPSessionResult>;
harExport(params?: BrowserContextHarExportParams, metadata?: Metadata): Promise<BrowserContextHarExportResult>;
createTempFile(params: BrowserContextCreateTempFileParams, metadata?: Metadata): Promise<BrowserContextCreateTempFileResult>;
}
export type BrowserContextBindingCallEvent = {
binding: BindingCallChannel,
@ -1275,6 +1279,15 @@ export type BrowserContextHarExportOptions = {};
export type BrowserContextHarExportResult = {
artifact: ArtifactChannel,
};
export type BrowserContextCreateTempFileParams = {
name: string,
};
export type BrowserContextCreateTempFileOptions = {
};
export type BrowserContextCreateTempFileResult = {
writableStream: WritableStreamChannel,
};
export interface BrowserContextEvents {
'bindingCall': BrowserContextBindingCallEvent;
@ -1864,6 +1877,7 @@ export interface FrameChannel extends FrameEventTarget, Channel {
selectOption(params: FrameSelectOptionParams, metadata?: Metadata): Promise<FrameSelectOptionResult>;
setContent(params: FrameSetContentParams, metadata?: Metadata): Promise<FrameSetContentResult>;
setInputFiles(params: FrameSetInputFilesParams, metadata?: Metadata): Promise<FrameSetInputFilesResult>;
setInputFilePaths(params: FrameSetInputFilePathsParams, metadata?: Metadata): Promise<FrameSetInputFilePathsResult>;
tap(params: FrameTapParams, metadata?: Metadata): Promise<FrameTapResult>;
textContent(params: FrameTextContentParams, metadata?: Metadata): Promise<FrameTextContentResult>;
title(params?: FrameTitleParams, metadata?: Metadata): Promise<FrameTitleResult>;
@ -2348,6 +2362,22 @@ export type FrameSetInputFilesOptions = {
noWaitAfter?: boolean,
};
export type FrameSetInputFilesResult = void;
export type FrameSetInputFilePathsParams = {
selector: string,
strict?: boolean,
localPaths?: string[],
streams?: WritableStreamChannel[],
timeout?: number,
noWaitAfter?: boolean,
};
export type FrameSetInputFilePathsOptions = {
strict?: boolean,
localPaths?: string[],
streams?: WritableStreamChannel[],
timeout?: number,
noWaitAfter?: boolean,
};
export type FrameSetInputFilePathsResult = void;
export type FrameTapParams = {
selector: string,
strict?: boolean,
@ -2633,6 +2663,7 @@ export interface ElementHandleChannel extends ElementHandleEventTarget, JSHandle
selectOption(params: ElementHandleSelectOptionParams, metadata?: Metadata): Promise<ElementHandleSelectOptionResult>;
selectText(params: ElementHandleSelectTextParams, metadata?: Metadata): Promise<ElementHandleSelectTextResult>;
setInputFiles(params: ElementHandleSetInputFilesParams, metadata?: Metadata): Promise<ElementHandleSetInputFilesResult>;
setInputFilePaths(params: ElementHandleSetInputFilePathsParams, metadata?: Metadata): Promise<ElementHandleSetInputFilePathsResult>;
tap(params: ElementHandleTapParams, metadata?: Metadata): Promise<ElementHandleTapResult>;
textContent(params?: ElementHandleTextContentParams, metadata?: Metadata): Promise<ElementHandleTextContentResult>;
type(params: ElementHandleTypeParams, metadata?: Metadata): Promise<ElementHandleTypeResult>;
@ -2947,6 +2978,19 @@ export type ElementHandleSetInputFilesOptions = {
noWaitAfter?: boolean,
};
export type ElementHandleSetInputFilesResult = void;
export type ElementHandleSetInputFilePathsParams = {
localPaths?: string[],
streams?: WritableStreamChannel[],
timeout?: number,
noWaitAfter?: boolean,
};
export type ElementHandleSetInputFilePathsOptions = {
localPaths?: string[],
streams?: WritableStreamChannel[],
timeout?: number,
noWaitAfter?: boolean,
};
export type ElementHandleSetInputFilePathsResult = void;
export type ElementHandleTapParams = {
force?: boolean,
noWaitAfter?: boolean,
@ -3425,6 +3469,29 @@ export type StreamCloseResult = void;
export interface StreamEvents {
}
// ----------- WritableStream -----------
export type WritableStreamInitializer = {};
export interface WritableStreamEventTarget {
}
export interface WritableStreamChannel extends WritableStreamEventTarget, Channel {
_type_WritableStream: boolean;
write(params: WritableStreamWriteParams, metadata?: Metadata): Promise<WritableStreamWriteResult>;
close(params?: WritableStreamCloseParams, metadata?: Metadata): Promise<WritableStreamCloseResult>;
}
export type WritableStreamWriteParams = {
binary: Binary,
};
export type WritableStreamWriteOptions = {
};
export type WritableStreamWriteResult = void;
export type WritableStreamCloseParams = {};
export type WritableStreamCloseOptions = {};
export type WritableStreamCloseResult = void;
export interface WritableStreamEvents {
}
// ----------- CDPSession -----------
export type CDPSessionInitializer = {};
export interface CDPSessionEventTarget {
@ -4168,6 +4235,7 @@ export const commandsWithTracingSnapshots = new Set([
'Frame.selectOption',
'Frame.setContent',
'Frame.setInputFiles',
'Frame.setInputFilePaths',
'Frame.tap',
'Frame.textContent',
'Frame.type',
@ -4203,6 +4271,7 @@ export const commandsWithTracingSnapshots = new Set([
'ElementHandle.selectOption',
'ElementHandle.selectText',
'ElementHandle.setInputFiles',
'ElementHandle.setInputFilePaths',
'ElementHandle.tap',
'ElementHandle.textContent',
'ElementHandle.type',
@ -4221,6 +4290,7 @@ export const pausesBeforeInputActions = new Set([
'Frame.press',
'Frame.selectOption',
'Frame.setInputFiles',
'Frame.setInputFilePaths',
'Frame.tap',
'Frame.type',
'Frame.uncheck',
@ -4232,6 +4302,7 @@ export const pausesBeforeInputActions = new Set([
'ElementHandle.press',
'ElementHandle.selectOption',
'ElementHandle.setInputFiles',
'ElementHandle.setInputFilePaths',
'ElementHandle.tap',
'ElementHandle.type',
'ElementHandle.uncheck'

View file

@ -862,6 +862,13 @@ BrowserContext:
returns:
artifact: Artifact
createTempFile:
parameters:
name: string
returns:
writableStream: WritableStream
events:
bindingCall:
@ -1747,6 +1754,24 @@ Frame:
snapshot: true
pausesBeforeInput: true
# This method should be used if one of the files is large (>50Mb).
setInputFilePaths:
parameters:
selector: string
strict: boolean?
# Only one of localPaths and streams should be present.
localPaths:
type: array?
items: string
streams:
type: array?
items: WritableStream
timeout: number?
noWaitAfter: boolean?
tracing:
snapshot: true
pausesBeforeInput: true
tap:
parameters:
selector: string
@ -2272,6 +2297,22 @@ ElementHandle:
snapshot: true
pausesBeforeInput: true
# This method should be used if one of the files is large (>50Mb).
setInputFilePaths:
parameters:
# Only one of localPaths and streams should be present.
localPaths:
type: array?
items: string
streams:
type: array?
items: WritableStream
timeout: number?
noWaitAfter: boolean?
tracing:
snapshot: true
pausesBeforeInput: true
tap:
parameters:
force: boolean?
@ -2658,6 +2699,18 @@ Stream:
close:
WritableStream:
type: interface
commands:
write:
parameters:
binary: binary
close:
CDPSession:
type: interface

View file

@ -503,6 +503,9 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme {
frame: tOptional(tChannel('Frame')),
});
scheme.BrowserContextHarExportParams = tOptional(tObject({}));
scheme.BrowserContextCreateTempFileParams = tObject({
name: tString,
});
scheme.PageSetDefaultNavigationTimeoutNoReplyParams = tObject({
timeout: tOptional(tNumber),
});
@ -884,6 +887,14 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme {
timeout: tOptional(tNumber),
noWaitAfter: tOptional(tBoolean),
});
scheme.FrameSetInputFilePathsParams = tObject({
selector: tString,
strict: tOptional(tBoolean),
localPaths: tOptional(tArray(tString)),
streams: tOptional(tArray(tChannel('WritableStream'))),
timeout: tOptional(tNumber),
noWaitAfter: tOptional(tBoolean),
});
scheme.FrameTapParams = tObject({
selector: tString,
strict: tOptional(tBoolean),
@ -1104,6 +1115,12 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme {
timeout: tOptional(tNumber),
noWaitAfter: tOptional(tBoolean),
});
scheme.ElementHandleSetInputFilePathsParams = tObject({
localPaths: tOptional(tArray(tString)),
streams: tOptional(tArray(tChannel('WritableStream'))),
timeout: tOptional(tNumber),
noWaitAfter: tOptional(tBoolean),
});
scheme.ElementHandleTapParams = tObject({
force: tOptional(tBoolean),
noWaitAfter: tOptional(tBoolean),
@ -1222,6 +1239,10 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme {
size: tOptional(tNumber),
});
scheme.StreamCloseParams = tOptional(tObject({}));
scheme.WritableStreamWriteParams = tObject({
binary: tBinary,
});
scheme.WritableStreamCloseParams = tOptional(tObject({}));
scheme.CDPSessionSendParams = tObject({
method: tString,
params: tOptional(tAny),

View file

@ -28,6 +28,7 @@ import { Progress } from './progress';
import { Selectors } from './selectors';
import * as types from './types';
import path from 'path';
import fs from 'fs';
import { CallMetadata, serverSideCallMetadata, SdkObject } from './instrumentation';
import { Debugger } from './supplements/debugger';
import { Tracing } from './trace/recorder/tracing';
@ -66,6 +67,7 @@ export abstract class BrowserContext extends SdkObject {
readonly tracing: Tracing;
readonly fetchRequest: BrowserContextAPIRequestContext;
private _customCloseHandler?: () => Promise<any>;
readonly _tempDirs: string[] = [];
constructor(browser: Browser, options: types.BrowserContextOptions, browserContextId: string | undefined) {
super(browser, 'browser-context');
@ -274,6 +276,10 @@ export abstract class BrowserContext extends SdkObject {
await Promise.all(Array.from(this._downloads).map(download => download.artifact.deleteOnContextClose()));
}
private async _deleteAllTempDirs(): Promise<void> {
await Promise.all(this._tempDirs.map(async dir => await fs.promises.unlink(dir).catch(e => {})));
}
setCustomCloseHandler(handler: (() => Promise<any>) | undefined) {
this._customCloseHandler = handler;
}
@ -308,6 +314,7 @@ export abstract class BrowserContext extends SdkObject {
// We delete downloads after context closure
// so that browser does not write to the download file anymore.
promises.push(this._deleteAllDownloads());
promises.push(this._deleteAllTempDirs());
await Promise.all(promises);
// Custom handler should trigger didCloseInternal itself.

View file

@ -322,6 +322,17 @@ export class CRPage implements PageDelegate {
injected.setInputFiles(node, files), files);
}
async setInputFilePaths(handle: dom.ElementHandle<HTMLInputElement>, files: string[]): Promise<void> {
const frame = await handle.ownerFrame();
if (!frame)
throw new Error('Cannot set input files to detached input element');
const parentSession = this._sessionForFrame(frame);
await parentSession._client.send('DOM.setFileInputFiles', {
objectId: handle._objectId,
files
});
}
async adoptElementHandle<T extends Node>(handle: dom.ElementHandle<T>, to: dom.FrameExecutionContext): Promise<dom.ElementHandle<T>> {
return this._sessionForHandle(handle)._adoptElementHandle<T>(handle, to);
}

View file

@ -31,6 +31,7 @@ import { TimeoutOptions } from '../common/types';
import { isUnderTest } from '../utils/utils';
type SetInputFilesFiles = channels.ElementHandleSetInputFilesParams['files'];
export type InputFilesItems = { files?: SetInputFilesFiles, localPaths?: string[] };
type ActionName = 'click' | 'hover' | 'dblclick' | 'tap' | 'move and up' | 'move and down';
export class NonRecoverableDOMError extends Error {
@ -602,19 +603,23 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
}, this._page._timeoutSettings.timeout(options));
}
async setInputFiles(metadata: CallMetadata, files: SetInputFilesFiles, options: types.NavigatingActionWaitOptions) {
async setInputFiles(metadata: CallMetadata, items: InputFilesItems, options: types.NavigatingActionWaitOptions) {
const controller = new ProgressController(metadata, this);
return controller.run(async progress => {
const result = await this._setInputFiles(progress, files, options);
const result = await this._setInputFiles(progress, items, options);
return assertDone(throwRetargetableDOMError(result));
}, this._page._timeoutSettings.timeout(options));
}
async _setInputFiles(progress: Progress, files: SetInputFilesFiles, options: types.NavigatingActionWaitOptions): Promise<'error:notconnected' | 'done'> {
for (const payload of files) {
if (!payload.mimeType)
payload.mimeType = mime.getType(payload.name) || 'application/octet-stream';
async _setInputFiles(progress: Progress, items: InputFilesItems, options: types.NavigatingActionWaitOptions): Promise<'error:notconnected' | 'done'> {
const { files, localPaths } = items;
if (files) {
for (const payload of files) {
if (!payload.mimeType)
payload.mimeType = mime.getType(payload.name) || 'application/octet-stream';
}
}
const multiple = files && files.length > 1 || localPaths && localPaths.length > 1;
const result = await this.evaluateHandleInUtility(([injected, node, multiple]): Element | undefined => {
const element = injected.retarget(node, 'follow-label');
if (!element)
@ -624,14 +629,17 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
if (multiple && !(element as HTMLInputElement).multiple)
throw injected.createStacklessError('Non-multiple file input can only accept single file');
return element;
}, files.length > 1);
}, multiple);
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.
await this._page._delegate.setInputFiles(retargeted, files as types.FilePayload[]);
if (localPaths)
await this._page._delegate.setInputFilePaths(retargeted, localPaths);
else
await this._page._delegate.setInputFiles(retargeted, files as types.FilePayload[]);
});
await this._page._doSlowMo();
return 'done';

View file

@ -532,6 +532,10 @@ export class FFPage implements PageDelegate {
injected.setInputFiles(node, files), files);
}
async setInputFilePaths(handle: dom.ElementHandle<HTMLInputElement>, files: string[]): Promise<void> {
throw new Error('Not implemented');
}
async adoptElementHandle<T extends Node>(handle: dom.ElementHandle<T>, to: dom.FrameExecutionContext): Promise<dom.ElementHandle<T>> {
const result = await this._session.send('Page.adoptNode', {
frameId: handle._context.frame._id,

View file

@ -37,6 +37,7 @@ import { isSessionClosedError } from './protocolError';
import { isInvalidSelectorError, splitSelectorByFrame, stringifySelector, ParsedSelector } from './common/selectorParser';
import { SelectorInfo } from './selectors';
import { ScreenshotOptions } from './screenshotter';
import { InputFilesItems } from './dom';
type ContextData = {
contextPromise: ManualPromise<dom.FrameExecutionContext | Error>;
@ -1225,10 +1226,10 @@ export class Frame extends SdkObject {
}, this._page._timeoutSettings.timeout(options));
}
async setInputFiles(metadata: CallMetadata, selector: string, files: channels.ElementHandleSetInputFilesParams['files'], options: types.NavigatingActionWaitOptions = {}): Promise<void> {
async setInputFiles(metadata: CallMetadata, selector: string, items: InputFilesItems, options: types.NavigatingActionWaitOptions = {}): Promise<channels.FrameSetInputFilesResult> {
const controller = new ProgressController(metadata, this);
return controller.run(async progress => {
return dom.assertDone(await this._retryWithProgressIfNotConnected(progress, selector, options.strict, handle => handle._setInputFiles(progress, files, options)));
return dom.assertDone(await this._retryWithProgressIfNotConnected(progress, selector, options.strict, handle => handle._setInputFiles(progress, items, options)));
}, this._page._timeoutSettings.timeout(options));
}

View file

@ -70,6 +70,7 @@ export interface PageDelegate {
getOwnerFrame(handle: dom.ElementHandle): Promise<string | null>; // Returns frameId.
getContentQuads(handle: dom.ElementHandle): Promise<types.Quad[] | null>;
setInputFiles(handle: dom.ElementHandle<HTMLInputElement>, files: types.FilePayload[]): Promise<void>;
setInputFilePaths(handle: dom.ElementHandle<HTMLInputElement>, files: string[]): Promise<void>;
getBoundingBox(handle: dom.ElementHandle): Promise<types.Rect | null>;
getFrameElement(frame: frames.Frame): Promise<dom.ElementHandle>;
scrollRectIntoViewIfNeeded(handle: dom.ElementHandle, rect?: types.Rect): Promise<'error:notvisible' | 'error:notconnected' | 'done'>;

View file

@ -930,6 +930,10 @@ export class WKPage implements PageDelegate {
await this._session.send('DOM.setInputFiles', { objectId, files: protocolFiles });
}
async setInputFilePaths(handle: dom.ElementHandle<HTMLInputElement>, files: string[]): Promise<void> {
throw new Error('Not implemented');
}
async adoptElementHandle<T extends Node>(handle: dom.ElementHandle<T>, to: dom.FrameExecutionContext): Promise<dom.ElementHandle<T>> {
const result = await this._session.sendMayFail('DOM.resolveNode', {
objectId: handle._objectId,

View file

@ -4,8 +4,8 @@
<title>File upload test</title>
</head>
<body>
<form action="/input/fileupload.html">
<input type="file">
<form action="/upload" method="post" enctype="multipart/form-data">
<input type="file" name="file1">
<input type="submit">
</form>
</body>

View file

@ -21,6 +21,7 @@ import { getUserAgent } from '../packages/playwright-core/lib/utils/utils';
import WebSocket from 'ws';
import { expect, playwrightTest as test } from './config/browserTest';
import { parseTrace, suppressCertificateWarning } from './config/utils';
import formidable from 'formidable';
test.slow(true, 'All connect tests are slow');
@ -572,3 +573,57 @@ test('should fulfill with global fetch result', async ({ browserType, startRemot
expect(response.status()).toBe(200);
expect(await response.json()).toEqual({ 'foo': 'bar' });
});
test('should upload large file', async ({ browserType, startRemoteServer, server, browserName }, testInfo) => {
test.skip(browserName !== 'chromium');
test.slow();
const remoteServer = await startRemoteServer();
const browser = await browserType.connect(remoteServer.wsEndpoint());
const context = await browser.newContext();
const page = await context.newPage();
await page.goto(server.PREFIX + '/input/fileupload.html');
const uploadFile = testInfo.outputPath('200MB.zip');
const str = 'A'.repeat(4 * 1024);
const stream = fs.createWriteStream(uploadFile);
for (let i = 0; i < 50 * 1024; i++) {
await new Promise<void>((fulfill, reject) => {
stream.write(str, err => {
if (err)
reject(err);
else
fulfill();
});
});
}
await new Promise(f => stream.end(f));
const input = page.locator('input[type="file"]');
const events = await input.evaluateHandle(e => {
const events = [];
e.addEventListener('input', () => events.push('input'));
e.addEventListener('change', () => events.push('change'));
return events;
});
await input.setInputFiles(uploadFile);
expect(await input.evaluate(e => (e as HTMLInputElement).files[0].name)).toBe('200MB.zip');
expect(await events.evaluate(e => e)).toEqual(['input', 'change']);
const serverFilePromise = new Promise<formidable.File>(fulfill => {
server.setRoute('/upload', async (req, res) => {
const form = new formidable.IncomingForm({ uploadDir: testInfo.outputPath() });
form.parse(req, function(err, fields, f) {
res.end();
const files = f as Record<string, formidable.File>;
fulfill(files.file1);
});
});
});
const [file1] = await Promise.all([
serverFilePromise,
page.click('input[type=submit]')
]);
expect(file1.originalFilename).toBe('200MB.zip');
expect(file1.size).toBe(200 * 1024 * 1024);
await Promise.all([uploadFile, file1.filepath].map(fs.promises.unlink));
});

View file

@ -36,6 +36,53 @@ it('should upload the file', async ({ page, server, asset }) => {
}, input)).toBe('contents of the file');
});
it('should upload large file', async ({ page, server, browserName }, testInfo) => {
it.skip(browserName !== 'chromium');
it.slow();
await page.goto(server.PREFIX + '/input/fileupload.html');
const uploadFile = testInfo.outputPath('200MB.zip');
const str = 'A'.repeat(4 * 1024);
const stream = fs.createWriteStream(uploadFile);
for (let i = 0; i < 50 * 1024; i++) {
await new Promise<void>((fulfill, reject) => {
stream.write(str, err => {
if (err)
reject(err);
else
fulfill();
});
});
}
await new Promise(f => stream.end(f));
const input = page.locator('input[type="file"]');
const events = await input.evaluateHandle(e => {
const events = [];
e.addEventListener('input', () => events.push('input'));
e.addEventListener('change', () => events.push('change'));
return events;
});
await input.setInputFiles(uploadFile);
expect(await input.evaluate(e => (e as HTMLInputElement).files[0].name)).toBe('200MB.zip');
expect(await events.evaluate(e => e)).toEqual(['input', 'change']);
const serverFilePromise = new Promise<formidable.File>(fulfill => {
server.setRoute('/upload', async (req, res) => {
const form = new formidable.IncomingForm({ uploadDir: testInfo.outputPath() });
form.parse(req, function(err, fields, f) {
res.end();
const files = f as Record<string, formidable.File>;
fulfill(files.file1);
});
});
});
const [file1] = await Promise.all([
serverFilePromise,
page.click('input[type=submit]')
]);
expect(file1.originalFilename).toBe('200MB.zip');
expect(file1.size).toBe(200 * 1024 * 1024);
await Promise.all([uploadFile, file1.filepath].map(fs.promises.unlink));
});
it('should work @smoke', async ({ page, asset }) => {
await page.setContent(`<input type=file>`);
await page.setInputFiles('input', asset('file-to-upload.txt'));

View file

@ -235,9 +235,11 @@ class TestServer {
throw error;
});
request.postBody = new Promise(resolve => {
let body = Buffer.from([]);
request.on('data', chunk => body = Buffer.concat([body, chunk]));
request.on('end', () => resolve(body));
const chunks = [];
request.on('data', chunk => {
chunks.push(chunk);
});
request.on('end', () => resolve(Buffer.concat(chunks)));
});
const path = url.parse(request.url).path;
this.debugServer(`request ${request.method} ${path}`);