diff --git a/packages/playwright-core/src/client/connection.ts b/packages/playwright-core/src/client/connection.ts index 26b16e265f..616565ed0a 100644 --- a/packages/playwright-core/src/client/connection.ts +++ b/packages/playwright-core/src/client/connection.ts @@ -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); } diff --git a/packages/playwright-core/src/client/elementHandle.ts b/packages/playwright-core/src/client/elementHandle.ts index 8c4816059a..61f81b59f6 100644 --- a/packages/playwright-core/src/client/elementHandle.ts +++ b/packages/playwright-core/src/client/elementHandle.ts @@ -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 extends JSHandle implements api.ElementHandle { readonly _elementChannel: channels.ElementHandleChannel; @@ -139,7 +146,16 @@ export class ElementHandle extends JSHandle 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 { @@ -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 { - 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 { + 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 { diff --git a/packages/playwright-core/src/client/frame.ts b/packages/playwright-core/src/client/frame.ts index 32e2fcc021..2bc6e204bf 100644 --- a/packages/playwright-core/src/client/frame.ts +++ b/packages/playwright-core/src/client/frame.ts @@ -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 implements api.Fr } async setInputFiles(selector: string, files: string | FilePayload | string[] | FilePayload[], options: channels.FrameSetInputFilesOptions = {}): Promise { - 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 = {}) { diff --git a/packages/playwright-core/src/client/writableStream.ts b/packages/playwright-core/src/client/writableStream.ts new file mode 100644 index 0000000000..4532e2ef78 --- /dev/null +++ b/packages/playwright-core/src/client/writableStream.ts @@ -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 { + 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); + } +} diff --git a/packages/playwright-core/src/dispatchers/browserContextDispatcher.ts b/packages/playwright-core/src/dispatchers/browserContextDispatcher.ts index 738a228ce5..dcc0747fd7 100644 --- a/packages/playwright-core/src/dispatchers/browserContextDispatcher.ts +++ b/packages/playwright-core/src/dispatchers/browserContextDispatcher.ts @@ -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 implements channels.BrowserContextChannel { _type_EventTarget = true; @@ -96,6 +100,15 @@ export class BrowserContextDispatcher extends Dispatcher { + 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); } diff --git a/packages/playwright-core/src/dispatchers/elementHandlerDispatcher.ts b/packages/playwright-core/src/dispatchers/elementHandlerDispatcher.ts index 0b8be6f0f4..bec12cc334 100644 --- a/packages/playwright-core/src/dispatchers/elementHandlerDispatcher.ts +++ b/packages/playwright-core/src/dispatchers/elementHandlerDispatcher.ts @@ -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 { - 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 { + 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 { diff --git a/packages/playwright-core/src/dispatchers/frameDispatcher.ts b/packages/playwright-core/src/dispatchers/frameDispatcher.ts index 9d94229268..36a31ce6a2 100644 --- a/packages/playwright-core/src/dispatchers/frameDispatcher.ts +++ b/packages/playwright-core/src/dispatchers/frameDispatcher.ts @@ -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 implements channels.FrameChannel { _type_Frame = true; @@ -200,8 +201,18 @@ export class FrameDispatcher extends Dispatcher im return { values: await this._frame.selectOption(metadata, params.selector, elements, params.options || [], params) }; } - async setInputFiles(params: channels.FrameSetInputFilesParams, metadata: CallMetadata): Promise { - return await this._frame.setInputFiles(metadata, params.selector, params.files, params); + async setInputFiles(params: channels.FrameSetInputFilesParams, metadata: CallMetadata): Promise { + return await this._frame.setInputFiles(metadata, params.selector, { files: params.files }, params); + } + + async setInputFilePaths(params: channels.FrameSetInputFilePathsParams, metadata: CallMetadata): Promise { + 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 { diff --git a/packages/playwright-core/src/dispatchers/writableStreamDispatcher.ts b/packages/playwright-core/src/dispatchers/writableStreamDispatcher.ts new file mode 100644 index 0000000000..16465f1deb --- /dev/null +++ b/packages/playwright-core/src/dispatchers/writableStreamDispatcher.ts @@ -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 { + const stream = this._object.stream; + await new Promise((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(fulfill => stream.end(fulfill)); + } + + path(): string { + return this._object.stream.path as string; + } +} diff --git a/packages/playwright-core/src/protocol/channels.ts b/packages/playwright-core/src/protocol/channels.ts index c8c15e3307..35c8ba1bd4 100644 --- a/packages/playwright-core/src/protocol/channels.ts +++ b/packages/playwright-core/src/protocol/channels.ts @@ -30,6 +30,7 @@ export type InitializerTraits = 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 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 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; newCDPSession(params: BrowserContextNewCDPSessionParams, metadata?: Metadata): Promise; harExport(params?: BrowserContextHarExportParams, metadata?: Metadata): Promise; + createTempFile(params: BrowserContextCreateTempFileParams, metadata?: Metadata): Promise; } 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; setContent(params: FrameSetContentParams, metadata?: Metadata): Promise; setInputFiles(params: FrameSetInputFilesParams, metadata?: Metadata): Promise; + setInputFilePaths(params: FrameSetInputFilePathsParams, metadata?: Metadata): Promise; tap(params: FrameTapParams, metadata?: Metadata): Promise; textContent(params: FrameTextContentParams, metadata?: Metadata): Promise; title(params?: FrameTitleParams, metadata?: Metadata): Promise; @@ -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; selectText(params: ElementHandleSelectTextParams, metadata?: Metadata): Promise; setInputFiles(params: ElementHandleSetInputFilesParams, metadata?: Metadata): Promise; + setInputFilePaths(params: ElementHandleSetInputFilePathsParams, metadata?: Metadata): Promise; tap(params: ElementHandleTapParams, metadata?: Metadata): Promise; textContent(params?: ElementHandleTextContentParams, metadata?: Metadata): Promise; type(params: ElementHandleTypeParams, metadata?: Metadata): Promise; @@ -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; + close(params?: WritableStreamCloseParams, metadata?: Metadata): Promise; +} +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' diff --git a/packages/playwright-core/src/protocol/protocol.yml b/packages/playwright-core/src/protocol/protocol.yml index db70c9a36b..137ef4aac2 100644 --- a/packages/playwright-core/src/protocol/protocol.yml +++ b/packages/playwright-core/src/protocol/protocol.yml @@ -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 diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index 723c3f0507..6c66e7ba2e 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -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), diff --git a/packages/playwright-core/src/server/browserContext.ts b/packages/playwright-core/src/server/browserContext.ts index f15053fc32..cd2b7081fe 100644 --- a/packages/playwright-core/src/server/browserContext.ts +++ b/packages/playwright-core/src/server/browserContext.ts @@ -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; + 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 { + await Promise.all(this._tempDirs.map(async dir => await fs.promises.unlink(dir).catch(e => {}))); + } + setCustomCloseHandler(handler: (() => Promise) | 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. diff --git a/packages/playwright-core/src/server/chromium/crPage.ts b/packages/playwright-core/src/server/chromium/crPage.ts index a5457ad49f..60e7521600 100644 --- a/packages/playwright-core/src/server/chromium/crPage.ts +++ b/packages/playwright-core/src/server/chromium/crPage.ts @@ -322,6 +322,17 @@ export class CRPage implements PageDelegate { injected.setInputFiles(node, files), files); } + async setInputFilePaths(handle: dom.ElementHandle, files: string[]): Promise { + 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(handle: dom.ElementHandle, to: dom.FrameExecutionContext): Promise> { return this._sessionForHandle(handle)._adoptElementHandle(handle, to); } diff --git a/packages/playwright-core/src/server/dom.ts b/packages/playwright-core/src/server/dom.ts index dee816e81a..4aa832d90a 100644 --- a/packages/playwright-core/src/server/dom.ts +++ b/packages/playwright-core/src/server/dom.ts @@ -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 extends js.JSHandle { }, 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 extends js.JSHandle { 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; 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'; diff --git a/packages/playwright-core/src/server/firefox/ffPage.ts b/packages/playwright-core/src/server/firefox/ffPage.ts index 3e37cc5ea9..37965b642e 100644 --- a/packages/playwright-core/src/server/firefox/ffPage.ts +++ b/packages/playwright-core/src/server/firefox/ffPage.ts @@ -532,6 +532,10 @@ export class FFPage implements PageDelegate { injected.setInputFiles(node, files), files); } + async setInputFilePaths(handle: dom.ElementHandle, files: string[]): Promise { + throw new Error('Not implemented'); + } + async adoptElementHandle(handle: dom.ElementHandle, to: dom.FrameExecutionContext): Promise> { const result = await this._session.send('Page.adoptNode', { frameId: handle._context.frame._id, diff --git a/packages/playwright-core/src/server/frames.ts b/packages/playwright-core/src/server/frames.ts index 637f402f5e..978fef9e39 100644 --- a/packages/playwright-core/src/server/frames.ts +++ b/packages/playwright-core/src/server/frames.ts @@ -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; @@ -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 { + async setInputFiles(metadata: CallMetadata, selector: string, items: InputFilesItems, options: types.NavigatingActionWaitOptions = {}): Promise { 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)); } diff --git a/packages/playwright-core/src/server/page.ts b/packages/playwright-core/src/server/page.ts index df2bb31823..10c542d625 100644 --- a/packages/playwright-core/src/server/page.ts +++ b/packages/playwright-core/src/server/page.ts @@ -70,6 +70,7 @@ export interface PageDelegate { getOwnerFrame(handle: dom.ElementHandle): Promise; // Returns frameId. getContentQuads(handle: dom.ElementHandle): Promise; setInputFiles(handle: dom.ElementHandle, files: types.FilePayload[]): Promise; + setInputFilePaths(handle: dom.ElementHandle, files: string[]): Promise; getBoundingBox(handle: dom.ElementHandle): Promise; getFrameElement(frame: frames.Frame): Promise; scrollRectIntoViewIfNeeded(handle: dom.ElementHandle, rect?: types.Rect): Promise<'error:notvisible' | 'error:notconnected' | 'done'>; diff --git a/packages/playwright-core/src/server/webkit/wkPage.ts b/packages/playwright-core/src/server/webkit/wkPage.ts index 9d21c6dcb4..3aa0e250a0 100644 --- a/packages/playwright-core/src/server/webkit/wkPage.ts +++ b/packages/playwright-core/src/server/webkit/wkPage.ts @@ -930,6 +930,10 @@ export class WKPage implements PageDelegate { await this._session.send('DOM.setInputFiles', { objectId, files: protocolFiles }); } + async setInputFilePaths(handle: dom.ElementHandle, files: string[]): Promise { + throw new Error('Not implemented'); + } + async adoptElementHandle(handle: dom.ElementHandle, to: dom.FrameExecutionContext): Promise> { const result = await this._session.sendMayFail('DOM.resolveNode', { objectId: handle._objectId, diff --git a/tests/assets/input/fileupload.html b/tests/assets/input/fileupload.html index 85d2c7ce83..6486e5c288 100644 --- a/tests/assets/input/fileupload.html +++ b/tests/assets/input/fileupload.html @@ -4,8 +4,8 @@ File upload test -
- + +
diff --git a/tests/browsertype-connect.spec.ts b/tests/browsertype-connect.spec.ts index 08aba2a8f9..cfc95de0c7 100644 --- a/tests/browsertype-connect.spec.ts +++ b/tests/browsertype-connect.spec.ts @@ -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((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(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; + 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)); +}); + + diff --git a/tests/page/page-set-input-files.spec.ts b/tests/page/page-set-input-files.spec.ts index 9575386f75..dffafc3e72 100644 --- a/tests/page/page-set-input-files.spec.ts +++ b/tests/page/page-set-input-files.spec.ts @@ -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((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(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; + 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(``); await page.setInputFiles('input', asset('file-to-upload.txt')); diff --git a/utils/testserver/index.js b/utils/testserver/index.js index ea29bcb980..81fc385c0d 100644 --- a/utils/testserver/index.js +++ b/utils/testserver/index.js @@ -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}`);