Skip to content

Commit

Permalink
chore: short-cut localUtils usage in JS client
Browse files Browse the repository at this point in the history
  • Loading branch information
pavelfeldman committed Feb 9, 2025
1 parent d1163cb commit a09898f
Show file tree
Hide file tree
Showing 15 changed files with 524 additions and 375 deletions.
2 changes: 1 addition & 1 deletion packages/playwright-core/src/client/android.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ export class Android extends ChannelOwner<channels.AndroidChannel> implements ap
const headers = { 'x-playwright-browser': 'android', ...options.headers };
const localUtils = this._connection.localUtils();
const connectParams: channels.LocalUtilsConnectParams = { wsEndpoint, headers, slowMo: options.slowMo, timeout: options.timeout };
const { pipe } = await localUtils._channel.connect(connectParams);
const { pipe } = await localUtils.connect(connectParams);
const closePipe = () => pipe.close().catch(() => {});
const connection = new Connection(localUtils, this._platform, this._instrumentation);
connection.markAsRemote();
Expand Down
2 changes: 1 addition & 1 deletion packages/playwright-core/src/client/browserContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -485,7 +485,7 @@ export class BrowserContext extends ChannelOwner<channels.BrowserContextChannel>
const needCompressed = harParams.path.endsWith('.zip');
if (isCompressed && !needCompressed) {
await artifact.saveAs(harParams.path + '.tmp');
await this._connection.localUtils()._channel.harUnzip({ zipFile: harParams.path + '.tmp', harFile: harParams.path });
await this._connection.localUtils().harUnzip({ zipFile: harParams.path + '.tmp', harFile: harParams.path });
} else {
await artifact.saveAs(harParams.path);
}
Expand Down
2 changes: 1 addition & 1 deletion packages/playwright-core/src/client/browserType.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ export class BrowserType extends ChannelOwner<channels.BrowserTypeChannel> imple
};
if ((params as any).__testHookRedirectPortForwarding)
connectParams.socksProxyRedirectPortForTest = (params as any).__testHookRedirectPortForwarding;
const { pipe, headers: connectHeaders } = await localUtils._channel.connect(connectParams);
const { pipe, headers: connectHeaders } = await localUtils.connect(connectParams);
const closePipe = () => pipe.close().catch(() => {});
const connection = new Connection(localUtils, this._platform, this._instrumentation);
connection.markAsRemote();
Expand Down
2 changes: 1 addition & 1 deletion packages/playwright-core/src/client/connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ export class Connection extends EventEmitter {
const location = frames[0] ? { file: frames[0].file, line: frames[0].line, column: frames[0].column } : undefined;
const metadata: channels.Metadata = { apiName, location, internal: !apiName, stepId };
if (this._tracingCount && frames && type !== 'LocalUtils')
this._localUtils?._channel.addStackToTracingNoReply({ callData: { stack: frames, id } }).catch(() => {});
this._localUtils?.addStackToTracingNoReply({ callData: { stack: frames, id } }).catch(() => {});
// We need to exit zones before calling into the server, otherwise
// when we receive events from the server, we would be in an API zone.
zones.empty().run(() => this.onmessage({ ...message, metadata }));
Expand Down
7 changes: 4 additions & 3 deletions packages/playwright-core/src/client/elementHandle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,12 +192,13 @@ export class ElementHandle<T extends Node = Node> extends JSHandle<T> implements
return value === undefined ? null : value;
}

async screenshot(options: Omit<channels.ElementHandleScreenshotOptions, 'mask'> & { path?: string, mask?: Locator[] } = {}): Promise<Buffer> {
async screenshot(options: Omit<channels.ElementHandleScreenshotOptions, 'mask'> & { path?: string, mask?: api.Locator[] } = {}): Promise<Buffer> {
const mask = options.mask as Locator[] | undefined;
const copy: channels.ElementHandleScreenshotOptions = { ...options, mask: undefined };
if (!copy.type)
copy.type = determineScreenshotType(options);
if (options.mask) {
copy.mask = options.mask.map(locator => ({
if (mask) {
copy.mask = mask.map(locator => ({
frame: locator._frame._channel,
selector: locator._selector,
}));
Expand Down
6 changes: 3 additions & 3 deletions packages/playwright-core/src/client/harRouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export class HarRouter {
private _options: { urlMatch?: URLMatch; baseURL?: string; };

static async create(localUtils: LocalUtils, file: string, notFoundAction: HarNotFoundAction, options: { urlMatch?: URLMatch }): Promise<HarRouter> {
const { harId, error } = await localUtils._channel.harOpen({ file });
const { harId, error } = await localUtils.harOpen({ file });
if (error)
throw new Error(error);
return new HarRouter(localUtils, harId!, notFoundAction, options);
Expand All @@ -47,7 +47,7 @@ export class HarRouter {
private async _handle(route: Route) {
const request = route.request();

const response = await this._localUtils._channel.harLookup({
const response = await this._localUtils.harLookup({
harId: this._harId,
url: request.url(),
method: request.method(),
Expand Down Expand Up @@ -103,6 +103,6 @@ export class HarRouter {
}

dispose() {
this._localUtils._channel.harClose({ harId: this._harId }).catch(() => {});
this._localUtils.harClose({ harId: this._harId }).catch(() => {});
}
}
40 changes: 40 additions & 0 deletions packages/playwright-core/src/client/localUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,10 @@
*/

import { ChannelOwner } from './channelOwner';
import * as localUtils from '../common/localUtils';

import type { Size } from './types';
import type { HarBackend } from '../common/harBackend';
import type * as channels from '@protocol/channels';

type DeviceDescriptor = {
Expand All @@ -31,6 +33,8 @@ type Devices = { [name: string]: DeviceDescriptor };

export class LocalUtils extends ChannelOwner<channels.LocalUtilsChannel> {
readonly devices: Devices;
private _harBackends = new Map<string, HarBackend>();
private _stackSessions = new Map<string, localUtils.StackSession>();

constructor(parent: ChannelOwner, type: string, guid: string, initializer: channels.LocalUtilsInitializer) {
super(parent, type, guid, initializer);
Expand All @@ -39,4 +43,40 @@ export class LocalUtils extends ChannelOwner<channels.LocalUtilsChannel> {
for (const { name, descriptor } of initializer.deviceDescriptors)
this.devices[name] = descriptor;
}

async zip(params: channels.LocalUtilsZipParams): Promise<void> {
return await localUtils.zip(this._platform, this._stackSessions, params);
}

async harOpen(params: channels.LocalUtilsHarOpenParams): Promise<channels.LocalUtilsHarOpenResult> {
return await localUtils.harOpen(this._harBackends, params);
}

async harLookup(params: channels.LocalUtilsHarLookupParams): Promise<channels.LocalUtilsHarLookupResult> {
return await localUtils.harLookup(this._harBackends, params);
}

async harClose(params: channels.LocalUtilsHarCloseParams): Promise<void> {
return await localUtils.harClose(this._harBackends, params);
}

async harUnzip(params: channels.LocalUtilsHarUnzipParams): Promise<void> {
return await localUtils.harUnzip(params);
}

async tracingStarted(params: channels.LocalUtilsTracingStartedParams): Promise<channels.LocalUtilsTracingStartedResult> {
return await localUtils.tracingStarted(this._stackSessions, params);
}

async traceDiscarded(params: channels.LocalUtilsTraceDiscardedParams): Promise<void> {
return await localUtils.traceDiscarded(this._platform, this._stackSessions, params);
}

async addStackToTracingNoReply(params: channels.LocalUtilsAddStackToTracingNoReplyParams): Promise<void> {
return await localUtils.addStackToTracingNoReply(this._stackSessions, params);
}

async connect(params: channels.LocalUtilsConnectParams): Promise<channels.LocalUtilsConnectResult> {
return await this._channel.connect(params);
}
}
10 changes: 5 additions & 5 deletions packages/playwright-core/src/client/tracing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ export class Tracing extends ChannelOwner<channels.TracingChannel> implements ap
this._isTracing = true;
this._connection.setIsTracing(true);
}
const result = await this._connection.localUtils()._channel.tracingStarted({ tracesDir: this._tracesDir, traceName });
const result = await this._connection.localUtils().tracingStarted({ tracesDir: this._tracesDir, traceName });
this._stacksId = result.stacksId;
}

Expand All @@ -89,15 +89,15 @@ export class Tracing extends ChannelOwner<channels.TracingChannel> implements ap
// Not interested in artifacts.
await this._channel.tracingStopChunk({ mode: 'discard' });
if (this._stacksId)
await this._connection.localUtils()._channel.traceDiscarded({ stacksId: this._stacksId });
await this._connection.localUtils().traceDiscarded({ stacksId: this._stacksId });
return;
}

const isLocal = !this._connection.isRemote();

if (isLocal) {
const result = await this._channel.tracingStopChunk({ mode: 'entries' });
await this._connection.localUtils()._channel.zip({ zipFile: filePath, entries: result.entries!, mode: 'write', stacksId: this._stacksId, includeSources: this._includeSources });
await this._connection.localUtils().zip({ zipFile: filePath, entries: result.entries!, mode: 'write', stacksId: this._stacksId, includeSources: this._includeSources });
return;
}

Expand All @@ -106,7 +106,7 @@ export class Tracing extends ChannelOwner<channels.TracingChannel> implements ap
// The artifact may be missing if the browser closed while stopping tracing.
if (!result.artifact) {
if (this._stacksId)
await this._connection.localUtils()._channel.traceDiscarded({ stacksId: this._stacksId });
await this._connection.localUtils().traceDiscarded({ stacksId: this._stacksId });
return;
}

Expand All @@ -115,7 +115,7 @@ export class Tracing extends ChannelOwner<channels.TracingChannel> implements ap
await artifact.saveAs(filePath);
await artifact.delete();

await this._connection.localUtils()._channel.zip({ zipFile: filePath, entries: [], mode: 'append', stacksId: this._stacksId, includeSources: this._includeSources });
await this._connection.localUtils().zip({ zipFile: filePath, entries: [], mode: 'append', stacksId: this._stacksId, includeSources: this._includeSources });
}

_resetStackCounter() {
Expand Down
3 changes: 2 additions & 1 deletion packages/playwright-core/src/common/DEPS.list
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
[*]
../utils/
../utilsBundle.ts
../utilsBundle.ts
../zipBundle.ts
175 changes: 175 additions & 0 deletions packages/playwright-core/src/common/harBackend.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
/**
* 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 fs from 'fs';
import * as path from 'path';

import { createGuid } from '../utils/crypto';
import { ZipFile } from '../utils/zipFile';

import type { HeadersArray } from './types';
import type * as har from '@trace/har';

const redirectStatus = [301, 302, 303, 307, 308];

export class HarBackend {
readonly id = createGuid();
private _harFile: har.HARFile;
private _zipFile: ZipFile | null;
private _baseDir: string | null;

constructor(harFile: har.HARFile, baseDir: string | null, zipFile: ZipFile | null) {
this._harFile = harFile;
this._baseDir = baseDir;
this._zipFile = zipFile;
}

async lookup(url: string, method: string, headers: HeadersArray, postData: Buffer | undefined, isNavigationRequest: boolean): Promise<{
action: 'error' | 'redirect' | 'fulfill' | 'noentry',
message?: string,
redirectURL?: string,
status?: number,
headers?: HeadersArray,
body?: Buffer }> {
let entry;
try {
entry = await this._harFindResponse(url, method, headers, postData);
} catch (e) {
return { action: 'error', message: 'HAR error: ' + e.message };
}

if (!entry)
return { action: 'noentry' };

// If navigation is being redirected, restart it with the final url to ensure the document's url changes.
if (entry.request.url !== url && isNavigationRequest)
return { action: 'redirect', redirectURL: entry.request.url };

const response = entry.response;
try {
const buffer = await this._loadContent(response.content);
return {
action: 'fulfill',
status: response.status,
headers: response.headers,
body: buffer,
};
} catch (e) {
return { action: 'error', message: e.message };
}
}

private async _loadContent(content: { text?: string, encoding?: string, _file?: string }): Promise<Buffer> {
const file = content._file;
let buffer: Buffer;
if (file) {
if (this._zipFile)
buffer = await this._zipFile.read(file);
else
buffer = await fs.promises.readFile(path.resolve(this._baseDir!, file));
} else {
buffer = Buffer.from(content.text || '', content.encoding === 'base64' ? 'base64' : 'utf-8');
}
return buffer;
}

private async _harFindResponse(url: string, method: string, headers: HeadersArray, postData: Buffer | undefined): Promise<har.Entry | undefined> {
const harLog = this._harFile.log;
const visited = new Set<har.Entry>();
while (true) {
const entries: har.Entry[] = [];
for (const candidate of harLog.entries) {
if (candidate.request.url !== url || candidate.request.method !== method)
continue;
if (method === 'POST' && postData && candidate.request.postData) {
const buffer = await this._loadContent(candidate.request.postData);
if (!buffer.equals(postData)) {
const boundary = multipartBoundary(headers);
if (!boundary)
continue;
const candidataBoundary = multipartBoundary(candidate.request.headers);
if (!candidataBoundary)
continue;
// Try to match multipart/form-data ignroing boundary as it changes between requests.
if (postData.toString().replaceAll(boundary, '') !== buffer.toString().replaceAll(candidataBoundary, ''))
continue;
}
}
entries.push(candidate);
}

if (!entries.length)
return;

let entry = entries[0];

// Disambiguate using headers - then one with most matching headers wins.
if (entries.length > 1) {
const list: { candidate: har.Entry, matchingHeaders: number }[] = [];
for (const candidate of entries) {
const matchingHeaders = countMatchingHeaders(candidate.request.headers, headers);
list.push({ candidate, matchingHeaders });
}
list.sort((a, b) => b.matchingHeaders - a.matchingHeaders);
entry = list[0].candidate;
}

if (visited.has(entry))
throw new Error(`Found redirect cycle for ${url}`);

visited.add(entry);

// Follow redirects.
const locationHeader = entry.response.headers.find(h => h.name.toLowerCase() === 'location');
if (redirectStatus.includes(entry.response.status) && locationHeader) {
const locationURL = new URL(locationHeader.value, url);
url = locationURL.toString();
if ((entry.response.status === 301 || entry.response.status === 302) && method === 'POST' ||
entry.response.status === 303 && !['GET', 'HEAD'].includes(method)) {
// HTTP-redirect fetch step 13 (https://fetch.spec.whatwg.org/#http-redirect-fetch)
method = 'GET';
}
continue;
}

return entry;
}
}

dispose() {
this._zipFile?.close();
}
}

function countMatchingHeaders(harHeaders: har.Header[], headers: HeadersArray): number {
const set = new Set(headers.map(h => h.name.toLowerCase() + ':' + h.value));
let matches = 0;
for (const h of harHeaders) {
if (set.has(h.name.toLowerCase() + ':' + h.value))
++matches;
}
return matches;
}

function multipartBoundary(headers: HeadersArray) {
const contentType = headers.find(h => h.name.toLowerCase() === 'content-type');
if (!contentType?.value.includes('multipart/form-data'))
return undefined;
const boundary = contentType.value.match(/boundary=(\S+)/);
if (boundary)
return boundary[1];
return undefined;
}
Loading

0 comments on commit a09898f

Please sign in to comment.