Skip to content

Commit a8d8062

Browse files
authored
feat(chromium): large file uploads (#12860)
1 parent c721c5c commit a8d8062

File tree

22 files changed

+498
-23
lines changed

22 files changed

+498
-23
lines changed

packages/playwright-core/src/client/connection.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import { Playwright } from './playwright';
3232
import { Electron, ElectronApplication } from './electron';
3333
import * as channels from '../protocol/channels';
3434
import { Stream } from './stream';
35+
import { WritableStream } from './writableStream';
3536
import { debugLogger } from '../utils/debugLogger';
3637
import { SelectorsOwner } from './selectors';
3738
import { Android, AndroidSocket, AndroidDevice } from './android';
@@ -269,6 +270,9 @@ export class Connection extends EventEmitter {
269270
case 'Worker':
270271
result = new Worker(parent, type, guid, initializer);
271272
break;
273+
case 'WritableStream':
274+
result = new WritableStream(parent, type, guid, initializer);
275+
break;
272276
default:
273277
throw new Error('Missing type ' + type);
274278
}

packages/playwright-core/src/client/elementHandle.ts

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,13 @@ import path from 'path';
2626
import { assert, isString, mkdirIfNeeded } from '../utils/utils';
2727
import * as api from '../../types/types';
2828
import * as structs from '../../types/structs';
29+
import { BrowserContext } from './browserContext';
30+
import { WritableStream } from './writableStream';
31+
import { pipeline } from 'stream';
32+
import { promisify } from 'util';
33+
import { debugLogger } from '../utils/debugLogger';
34+
35+
const pipelineAsync = promisify(pipeline);
2936

3037
export class ElementHandle<T extends Node = Node> extends JSHandle<T> implements api.ElementHandle {
3138
readonly _elementChannel: channels.ElementHandleChannel;
@@ -139,7 +146,16 @@ export class ElementHandle<T extends Node = Node> extends JSHandle<T> implements
139146
}
140147

141148
async setInputFiles(files: string | FilePayload | string[] | FilePayload[], options: channels.ElementHandleSetInputFilesOptions = {}) {
142-
await this._elementChannel.setInputFiles({ files: await convertInputFiles(files), ...options });
149+
const frame = await this.ownerFrame();
150+
if (!frame)
151+
throw new Error('Cannot set input files to detached element');
152+
const converted = await convertInputFiles(files, frame.page().context());
153+
if (converted.files) {
154+
await this._elementChannel.setInputFiles({ files: converted.files, ...options });
155+
} else {
156+
debugLogger.log('api', 'switching to large files mode');
157+
await this._elementChannel.setInputFilePaths({ ...converted, ...options });
158+
}
143159
}
144160

145161
async focus(): Promise<void> {
@@ -241,8 +257,35 @@ export function convertSelectOptionValues(values: string | api.ElementHandle | S
241257
}
242258

243259
type SetInputFilesFiles = channels.ElementHandleSetInputFilesParams['files'];
244-
export async function convertInputFiles(files: string | FilePayload | string[] | FilePayload[]): Promise<SetInputFilesFiles> {
245-
const items: (string | FilePayload)[] = Array.isArray(files) ? files : [ files ];
260+
type InputFilesList = {
261+
files?: SetInputFilesFiles;
262+
localPaths?: string[];
263+
streams?: channels.WritableStreamChannel[];
264+
};
265+
export async function convertInputFiles(files: string | FilePayload | string[] | FilePayload[], context: BrowserContext): Promise<InputFilesList> {
266+
const items: (string | FilePayload)[] = Array.isArray(files) ? files.slice() : [ files ];
267+
268+
const sizeLimit = 50 * 1024 * 1024;
269+
const hasLargeBuffer = items.find(item => typeof item === 'object' && item.buffer && item.buffer.byteLength > sizeLimit);
270+
if (hasLargeBuffer)
271+
throw new Error('Cannot set buffer larger than 50Mb, please write it to a file and pass its path instead.');
272+
273+
const stats = await Promise.all(items.filter(isString).map(item => fs.promises.stat(item as string)));
274+
const hasLargeFile = !!stats.find(s => s.size > sizeLimit);
275+
if (hasLargeFile) {
276+
if (context._connection.isRemote()) {
277+
const streams: channels.WritableStreamChannel[] = await Promise.all(items.map(async item => {
278+
assert(isString(item));
279+
const { writableStream: stream } = await context._channel.createTempFile({ name: path.basename(item) });
280+
const writable = WritableStream.from(stream);
281+
await pipelineAsync(fs.createReadStream(item), writable.stream());
282+
return stream;
283+
}));
284+
return { streams };
285+
}
286+
return { localPaths: items as string[] };
287+
}
288+
246289
const filePayloads: SetInputFilesFiles = await Promise.all(items.map(async item => {
247290
if (typeof item === 'string') {
248291
return {
@@ -257,7 +300,7 @@ export async function convertInputFiles(files: string | FilePayload | string[] |
257300
};
258301
}
259302
}));
260-
return filePayloads;
303+
return { files: filePayloads };
261304
}
262305

263306
export function determineScreenshotType(options: { path?: string, type?: 'png' | 'jpeg' }): 'png' | 'jpeg' | undefined {

packages/playwright-core/src/client/frame.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import { LifecycleEvent, URLMatch, SelectOption, SelectOptionOptions, FilePayloa
3131
import { urlMatches } from './clientHelper';
3232
import * as api from '../../types/types';
3333
import * as structs from '../../types/structs';
34+
import { debugLogger } from '../utils/debugLogger';
3435

3536
export type WaitForNavigationOptions = {
3637
timeout?: number,
@@ -355,7 +356,13 @@ export class Frame extends ChannelOwner<channels.FrameChannel> implements api.Fr
355356
}
356357

357358
async setInputFiles(selector: string, files: string | FilePayload | string[] | FilePayload[], options: channels.FrameSetInputFilesOptions = {}): Promise<void> {
358-
await this._channel.setInputFiles({ selector, files: await convertInputFiles(files), ...options });
359+
const converted = await convertInputFiles(files, this.page().context());
360+
if (converted.files) {
361+
await this._channel.setInputFiles({ selector, files: converted.files, ...options });
362+
} else {
363+
debugLogger.log('api', 'switching to large files mode');
364+
await this._channel.setInputFilePaths({ selector, ...converted, ...options });
365+
}
359366
}
360367

361368
async type(selector: string, text: string, options: channels.FrameTypeOptions = {}) {
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
/**
2+
* Copyright (c) Microsoft Corporation.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import { Writable } from 'stream';
18+
import * as channels from '../protocol/channels';
19+
import { ChannelOwner } from './channelOwner';
20+
21+
export class WritableStream extends ChannelOwner<channels.WritableStreamChannel> {
22+
static from(Stream: channels.WritableStreamChannel): WritableStream {
23+
return (Stream as any)._object;
24+
}
25+
26+
constructor(parent: ChannelOwner, type: string, guid: string, initializer: channels.WritableStreamInitializer) {
27+
super(parent, type, guid, initializer);
28+
}
29+
30+
stream(): Writable {
31+
return new WritableStreamImpl(this._channel);
32+
}
33+
}
34+
35+
class WritableStreamImpl extends Writable {
36+
private _channel: channels.WritableStreamChannel;
37+
38+
constructor(channel: channels.WritableStreamChannel) {
39+
super();
40+
this._channel = channel;
41+
}
42+
43+
override async _write(chunk: any, encoding: BufferEncoding, callback: (error?: Error | null) => void) {
44+
const error = await this._channel.write({ binary: chunk.toString('base64') }).catch(e => e);
45+
callback(error || null);
46+
}
47+
48+
override async _final(callback: (error?: Error | null) => void) {
49+
// Stream might be destroyed after the connection was closed.
50+
const error = await this._channel.close().catch(e => e);
51+
callback(error || null);
52+
}
53+
}

packages/playwright-core/src/dispatchers/browserContextDispatcher.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ import { ArtifactDispatcher } from './artifactDispatcher';
2828
import { Artifact } from '../server/artifact';
2929
import { Request, Response } from '../server/network';
3030
import { TracingDispatcher } from './tracingDispatcher';
31+
import * as fs from 'fs';
32+
import * as path from 'path';
33+
import { createGuid } from '../utils/utils';
34+
import { WritableStreamDispatcher } from './writableStreamDispatcher';
3135

3236
export class BrowserContextDispatcher extends Dispatcher<BrowserContext, channels.BrowserContextChannel> implements channels.BrowserContextChannel {
3337
_type_EventTarget = true;
@@ -96,6 +100,15 @@ export class BrowserContextDispatcher extends Dispatcher<BrowserContext, channel
96100
}));
97101
}
98102

103+
async createTempFile(params: channels.BrowserContextCreateTempFileParams, metadata?: channels.Metadata): Promise<channels.BrowserContextCreateTempFileResult> {
104+
const dir = this._context._browser.options.artifactsDir;
105+
const tmpDir = path.join(dir, 'upload-' + createGuid());
106+
await fs.promises.mkdir(tmpDir);
107+
this._context._tempDirs.push(tmpDir);
108+
const file = fs.createWriteStream(path.join(tmpDir, params.name));
109+
return { writableStream: new WritableStreamDispatcher(this._scope, file) };
110+
}
111+
99112
async setDefaultNavigationTimeoutNoReply(params: channels.BrowserContextSetDefaultNavigationTimeoutNoReplyParams) {
100113
this._context.setDefaultNavigationTimeout(params.timeout);
101114
}

packages/playwright-core/src/dispatchers/elementHandlerDispatcher.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { DispatcherScope, existingDispatcher, lookupNullableDispatcher } from '.
2222
import { JSHandleDispatcher, serializeResult, parseArgument } from './jsHandleDispatcher';
2323
import { FrameDispatcher } from './frameDispatcher';
2424
import { CallMetadata } from '../server/instrumentation';
25+
import { WritableStreamDispatcher } from './writableStreamDispatcher';
2526

2627
export class ElementHandleDispatcher extends JSHandleDispatcher implements channels.ElementHandleChannel {
2728
_type_ElementHandle = true;
@@ -143,7 +144,17 @@ export class ElementHandleDispatcher extends JSHandleDispatcher implements chann
143144
}
144145

145146
async setInputFiles(params: channels.ElementHandleSetInputFilesParams, metadata: CallMetadata): Promise<void> {
146-
return await this._elementHandle.setInputFiles(metadata, params.files, params);
147+
return await this._elementHandle.setInputFiles(metadata, { files: params.files }, params);
148+
}
149+
150+
async setInputFilePaths(params: channels.ElementHandleSetInputFilePathsParams, metadata: CallMetadata): Promise<void> {
151+
let { localPaths } = params;
152+
if (!localPaths) {
153+
if (!params.streams)
154+
throw new Error('Neither localPaths nor streams is specified');
155+
localPaths = params.streams.map(c => (c as WritableStreamDispatcher).path());
156+
}
157+
return await this._elementHandle.setInputFiles(metadata, { localPaths }, params);
147158
}
148159

149160
async focus(params: channels.ElementHandleFocusParams, metadata: CallMetadata): Promise<void> {

packages/playwright-core/src/dispatchers/frameDispatcher.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { ElementHandleDispatcher } from './elementHandlerDispatcher';
2121
import { parseArgument, serializeResult } from './jsHandleDispatcher';
2222
import { ResponseDispatcher, RequestDispatcher } from './networkDispatchers';
2323
import { CallMetadata } from '../server/instrumentation';
24+
import { WritableStreamDispatcher } from './writableStreamDispatcher';
2425

2526
export class FrameDispatcher extends Dispatcher<Frame, channels.FrameChannel> implements channels.FrameChannel {
2627
_type_Frame = true;
@@ -200,8 +201,18 @@ export class FrameDispatcher extends Dispatcher<Frame, channels.FrameChannel> im
200201
return { values: await this._frame.selectOption(metadata, params.selector, elements, params.options || [], params) };
201202
}
202203

203-
async setInputFiles(params: channels.FrameSetInputFilesParams, metadata: CallMetadata): Promise<void> {
204-
return await this._frame.setInputFiles(metadata, params.selector, params.files, params);
204+
async setInputFiles(params: channels.FrameSetInputFilesParams, metadata: CallMetadata): Promise<channels.FrameSetInputFilesResult> {
205+
return await this._frame.setInputFiles(metadata, params.selector, { files: params.files }, params);
206+
}
207+
208+
async setInputFilePaths(params: channels.FrameSetInputFilePathsParams, metadata: CallMetadata): Promise<void> {
209+
let { localPaths } = params;
210+
if (!localPaths) {
211+
if (!params.streams)
212+
throw new Error('Neither localPaths nor streams is specified');
213+
localPaths = params.streams.map(c => (c as WritableStreamDispatcher).path());
214+
}
215+
return await this._frame.setInputFiles(metadata, params.selector, { localPaths }, params);
205216
}
206217

207218
async type(params: channels.FrameTypeParams, metadata: CallMetadata): Promise<void> {
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
/**
2+
* Copyright (c) Microsoft Corporation.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the 'License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import * as channels from '../protocol/channels';
18+
import { Dispatcher, DispatcherScope } from './dispatcher';
19+
import * as fs from 'fs';
20+
import { createGuid } from '../utils/utils';
21+
22+
export class WritableStreamDispatcher extends Dispatcher<{ guid: string, stream: fs.WriteStream }, channels.WritableStreamChannel> implements channels.WritableStreamChannel {
23+
_type_WritableStream = true;
24+
constructor(scope: DispatcherScope, stream: fs.WriteStream) {
25+
super(scope, { guid: 'writableStream@' + createGuid(), stream }, 'WritableStream', {});
26+
}
27+
28+
async write(params: channels.WritableStreamWriteParams): Promise<channels.WritableStreamWriteResult> {
29+
const stream = this._object.stream;
30+
await new Promise<void>((fulfill, reject) => {
31+
stream.write(Buffer.from(params.binary, 'base64'), error => {
32+
if (error)
33+
reject(error);
34+
else
35+
fulfill();
36+
});
37+
});
38+
}
39+
40+
async close() {
41+
const stream = this._object.stream;
42+
await new Promise<void>(fulfill => stream.end(fulfill));
43+
}
44+
45+
path(): string {
46+
return this._object.stream.path as string;
47+
}
48+
}

0 commit comments

Comments
 (0)