Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion packages/playwright-core/src/client/channelOwner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,10 @@ export abstract class ChannelOwner<T extends channels.Channel = channels.Channel
return await func(existingApiZone);

const stackTrace = captureLibraryStackTrace();
const apiZone: ApiZone = { title: options?.title, apiName: stackTrace.apiName, frames: stackTrace.frames, internal: options?.internal ?? false, reported: false, userData: undefined, stepId: undefined };
let apiName = stackTrace.apiName;
if (apiName.startsWith('_') || apiName.includes('._'))
apiName = options?.title ?? apiName;
const apiZone: ApiZone = { title: options?.title, apiName, frames: stackTrace.frames, internal: options?.internal ?? false, reported: false, userData: undefined, stepId: undefined };

try {
const result = await currentZone().with('apiZone', apiZone).run(async () => await func(apiZone));
Expand Down
3 changes: 1 addition & 2 deletions packages/playwright-core/src/client/clientInstrumentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,8 @@ import type { StackFrame } from './channels';
import type { Page } from './page';
import type { BrowserContextOptions } from './types';

// Instrumentation can mutate the data, for example change apiName or stepId.
// Instrumentation can mutate the data, for example change the stepId.
export interface ApiCallData {
apiName: string;
title?: string;
frames: StackFrame[];
userData: any;
Expand Down
52 changes: 27 additions & 25 deletions packages/playwright-core/src/client/frame.ts
Original file line number Diff line number Diff line change
Expand Up @@ -514,31 +514,33 @@ export class Frame extends ChannelOwner<channels.FrameChannel> implements api.Fr
return (await this._channel.title({}, kNoTimeout)).value;
}

async _expect(expression: string, options: Omit<channels.FrameExpectParams, 'expression'> & { timeout: number, signal?: AbortSignal }): Promise<ExpectResult> {
const { timeout, signal, ...rest } = options;
const params: channels.FrameExpectParams = { expression, ...rest, isNot: !!rest.isNot };
params.expectedValue = serializeArgument(rest.expectedValue);
try {
await this._channel.expect(params, { signal, timeout });
return { matches: !params.isNot };
} catch (e) {
if (e instanceof AbortError)
return { matches: !!params.isNot, errorMessage: 'Error: ' + assertionAbortedMessage(e.cause) };
if (!(e instanceof PlaywrightError))
throw e;
const details = e.details as channels.FrameExpectErrorDetails;
const received = details.received ? {
value: details.received.value !== undefined ? parseResult(details.received.value) : undefined,
ariaSnapshot: details.received.ariaSnapshot,
} : undefined;
return {
matches: !!params.isNot,
received,
log: e.log,
timedOut: details.timedOut,
errorMessage: details.customErrorMessage ? 'Error: ' + details.customErrorMessage : undefined,
};
}
async _expect(expression: string, options: Omit<channels.FrameExpectParams, 'expression'> & { timeout: number, signal?: AbortSignal, title?: string }): Promise<ExpectResult> {
const { timeout, signal, title, ...rest } = options;
return await this._wrapApiCall(async () => {
const params: channels.FrameExpectParams = { expression, ...rest, isNot: !!rest.isNot };
params.expectedValue = serializeArgument(rest.expectedValue);
try {
await this._channel.expect(params, { signal, timeout });
return { matches: !params.isNot };
} catch (e) {
if (e instanceof AbortError)
return { matches: !!params.isNot, errorMessage: 'Error: ' + assertionAbortedMessage(e.cause) };
if (!(e instanceof PlaywrightError))
throw e;
const details = e.details as channels.FrameExpectErrorDetails;
const received = details.received ? {
value: details.received.value !== undefined ? parseResult(details.received.value) : undefined,
ariaSnapshot: details.received.ariaSnapshot,
} : undefined;
return {
matches: !!params.isNot,
received,
log: e.log,
timedOut: details.timedOut,
errorMessage: details.customErrorMessage ? 'Error: ' + details.customErrorMessage : undefined,
};
}
}, { title });
}
}

Expand Down
53 changes: 28 additions & 25 deletions packages/playwright-core/src/client/page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ export type ExpectScreenshotOptions = Omit<channels.PageExpectScreenshotOptions,
signal?: AbortSignal,
isNot: boolean,
mask?: api.Locator[],
title?: string,
};

export class Page extends ChannelOwner<channels.PageChannel> implements api.Page {
Expand Down Expand Up @@ -650,31 +651,33 @@ export class Page extends ChannelOwner<channels.PageChannel> implements api.Page
}

async _expectScreenshot(options: ExpectScreenshotOptions): Promise<{ actual?: Buffer, previous?: Buffer, diff?: Buffer, errorMessage?: string, log?: string[], timedOut?: boolean}> {
const { timeout, signal, ...optionsWithoutTimeout } = options;
const mask = options?.mask ? options?.mask.map(locator => ({
frame: (locator as Locator)._frame._channel,
selector: (locator as Locator)._selector,
})) : undefined;
const locator = options.locator ? {
frame: (options.locator as Locator)._frame._channel,
selector: (options.locator as Locator)._selector,
} : undefined;
try {
const result = await this._channel.expectScreenshot({
...optionsWithoutTimeout,
isNot: !!options.isNot,
locator,
mask,
}, { timeout, signal });
return { actual: result.actual };
} catch (e) {
if (e instanceof AbortError)
return { errorMessage: 'Error: ' + assertionAbortedMessage(e.cause) };
if (!(e instanceof PlaywrightError))
throw e;
const details = e.details as channels.PageExpectScreenshotErrorDetails;
return { ...details, errorMessage: details.customErrorMessage };
}
const { timeout, signal, title, ...optionsWithoutTimeout } = options;
return await this._wrapApiCall(async () => {
const mask = options?.mask ? options?.mask.map(locator => ({
frame: (locator as Locator)._frame._channel,
selector: (locator as Locator)._selector,
})) : undefined;
const locator = options.locator ? {
frame: (options.locator as Locator)._frame._channel,
selector: (options.locator as Locator)._selector,
} : undefined;
try {
const result = await this._channel.expectScreenshot({
...optionsWithoutTimeout,
isNot: !!options.isNot,
locator,
mask,
}, { timeout, signal });
return { actual: result.actual };
} catch (e) {
if (e instanceof AbortError)
return { errorMessage: 'Error: ' + assertionAbortedMessage(e.cause) };
if (!(e instanceof PlaywrightError))
throw e;
const details = e.details as channels.PageExpectScreenshotErrorDetails;
return { ...details, errorMessage: details.customErrorMessage };
}
}, { title });
}

async title(): Promise<string> {
Expand Down
2 changes: 1 addition & 1 deletion packages/playwright-core/src/client/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,4 +149,4 @@ export type AnnotateOptions = { duration?: number, position?: AnnotatePosition,
export type RemoteAddr = channels.RemoteAddr;
export type SecurityDetails = channels.SecurityDetails;

export type FrameExpectParams = Omit<channels.FrameExpectParams, 'selector'|'expression'|'expectedValue'> & { expectedValue?: any, timeout: number, signal?: AbortSignal };
export type FrameExpectParams = Omit<channels.FrameExpectParams, 'selector'|'expression'|'expectedValue'> & { expectedValue?: any, timeout: number, signal?: AbortSignal, title?: string };
42 changes: 29 additions & 13 deletions packages/playwright-core/src/server/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,11 @@ import { TLSSocket } from 'tls';
import * as zlib from 'zlib';

import { createGuid } from '@utils/crypto';
import { httpHappyEyeballsAgent, httpsHappyEyeballsAgent, timingForSocket } from '@utils/happyEyeballs';
import { assert } from '@isomorphic/assert';
import { constructURLBasedOnBaseURL } from '@isomorphic/urlMatch';
import { eventsHelper } from '@utils/eventsHelper';
import { monotonicTime } from '@isomorphic/time';
import { createProxyAgent } from '@utils/network';
import { createProxyAgent, dualStackLookup, flattenAggregateError } from '@utils/network';
import { getUserAgent } from './userAgent';
import { BrowserContext, findMatchingHttpCredentials, verifyClientCertificates } from './browserContext';
import { Cookie, CookieStore, domainMatches, parseRawCookie } from './cookieStore';
Expand Down Expand Up @@ -342,9 +341,10 @@ export abstract class APIRequestContext extends SdkObject {
const resultPromise = new Promise<SendRequestResult>((fulfill, reject) => {
const requestConstructor: ((url: URL, options: http.RequestOptions, callback?: (res: http.IncomingMessage) => void) => http.ClientRequest)
= (url.protocol === 'https:' ? https : http).request;
// If we have a proxy agent already, do not override it.
const agent = options.agent || (url.protocol === 'https:' ? httpsHappyEyeballsAgent : httpHappyEyeballsAgent);
const requestOptions = { ...options, agent };
// Without an explicit proxy agent, the default global agent is used, which
// has keep-alive enabled and connects with Happy Eyeballs (autoSelectFamily).
const requestOptions = { ...options };
requestOptions.lookup = options.__testHookLookup ? lookupWithTestHook(options.__testHookLookup) : dualStackLookup;

const startAt = monotonicTime();
const startAtWallTime = Date.now();
Expand All @@ -366,7 +366,7 @@ export abstract class APIRequestContext extends SdkObject {
// https://github.com/nodejs/undici/blob/01a912e49a50c48009ed2639d2a457a6ec26752a/lib/dispatcher/client-h1.js#L735
if (responseReceived && isNetworkConnectionError(error))
return;
reject(error);
reject(flattenAggregateError(error));
};

const request = requestConstructor(url, requestOptions as any, async response => {
Expand Down Expand Up @@ -614,15 +614,13 @@ export abstract class APIRequestContext extends SdkObject {
return;
}

// happy eyeballs don't emit lookup and connect events, so we use our custom ones
const happyEyeBallsTimings = timingForSocket(socket);
dnsLookupAt = happyEyeBallsTimings.dnsLookupAt;
tcpConnectionAt = happyEyeBallsTimings.tcpConnectionAt;

// non-happy-eyeballs sockets
listeners.push(
eventsHelper.addEventListener(socket, 'lookup', () => { dnsLookupAt = monotonicTime(); }),
eventsHelper.addEventListener(socket, 'connect', () => { tcpConnectionAt = monotonicTime(); }),
eventsHelper.addEventListener(socket, 'connect', () => {
tcpConnectionAt = monotonicTime();
serverIPAddress = socket.remoteAddress;
serverPort = socket.remotePort;
}),
eventsHelper.addEventListener(socket, 'secureConnect', () => {
tlsHandshakeAt = monotonicTime();
captureSecurityDetails(socket);
Expand Down Expand Up @@ -865,3 +863,21 @@ function setBasicAuthorizationHeader(headers: { [name: string]: string }, creden
const encoded = Buffer.from(`${username || ''}:${password || ''}`).toString('base64');
setHeader(headers, 'authorization', `Basic ${encoded}`);
}

function lookupWithTestHook(testHookLookup: (hostname: string) => LookupAddress[]): net.LookupFunction {
return (hostname, options, callback) => {
setImmediate(() => {
let addresses: LookupAddress[];
try {
addresses = testHookLookup(hostname);
} catch (error) {
callback(error as NodeJS.ErrnoException, '');
return;
}
if (options.all)
callback(null, addresses);
else
callback(null, addresses[0].address, addresses[0].family);
});
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,10 @@ import tls from 'tls';
import { getProxyForUrl } from 'proxy-from-env';
import { SocksProxy } from '@utils/socksProxy';
import { debugLogger } from '@utils/debugLogger';
import { createSocket } from '@utils/happyEyeballs';
import { escapeHTML } from '@isomorphic/stringUtils';
import { generateSelfSignedCertificate } from '@utils/crypto';
import { rewriteErrorMessage } from '@utils/stackTrace';
import { createProxyAgent } from '@utils/network';
import { createProxyAgent, createSocket } from '@utils/network';
import { verifyClientCertificates } from './browserContext';
import type * as types from './types';
import type { SocksSocketClosedPayload, SocksSocketDataPayload, SocksSocketRequestedPayload } from '@utils/socksProxy';
Expand Down
9 changes: 5 additions & 4 deletions packages/playwright-core/src/server/transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
*/

import ws from 'ws';
import { httpHappyEyeballsAgent, httpsHappyEyeballsAgent } from '@utils/happyEyeballs';
import { dualStackLookup, flattenAggregateError } from '@utils/network';
import { makeWaitForNextTask } from '@utils/task';
import type { WebSocket } from 'ws';
import type { Progress } from './progress';
Expand Down Expand Up @@ -89,8 +89,9 @@ export class WebSocketTransport implements ConnectionTransport {
fulfill({});
});
transport._ws.on('error', event => {
progress?.log(`<ws connect error> ${logUrl} ${event.message}`);
reject(new Error('WebSocket error: ' + event.message));
const message = flattenAggregateError(event).message;
progress?.log(`<ws connect error> ${logUrl} ${message}`);
reject(new Error('WebSocket error: ' + message));
transport._ws.close();
});
transport._ws.on('unexpected-response', (request: ClientRequest, response: IncomingMessage) => {
Expand Down Expand Up @@ -137,7 +138,7 @@ export class WebSocketTransport implements ConnectionTransport {
maxPayload: 256 * 1024 * 1024, // 256Mb,
headers: options.headers,
followRedirects: options.followRedirects,
agent: (/^(https|wss):\/\//.test(url)) ? httpsHappyEyeballsAgent : httpHappyEyeballsAgent,
lookup: dualStackLookup,
perMessageDeflate,
});
this._ws.on('upgrade', response => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import path from 'path';
import ws from 'ws';
import { debugLogger, RecentLogsCollector } from '@utils/debugLogger';
import { removeFolders } from '@utils/fileUtils';
import { httpHappyEyeballsAgent, httpsHappyEyeballsAgent } from '@utils/happyEyeballs';
import { dualStackLookup } from '@utils/network';
import { headersArrayToObject } from '@isomorphic/headers';
import { Browser } from '../../browser';
import { helper } from '../../helper';
Expand Down Expand Up @@ -96,7 +96,7 @@ class DeferredWebSocketTransport implements ConnectOverCDPTransport {
maxPayload: 256 * 1024 * 1024,
headers: this._headers,
followRedirects: true,
agent: (/^(https|wss):\/\//.test(url)) ? httpsHappyEyeballsAgent : httpHappyEyeballsAgent,
lookup: dualStackLookup,
perMessageDeflate,
allowSynchronousEvents: false,
});
Expand Down
37 changes: 14 additions & 23 deletions packages/playwright/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,18 +97,16 @@ const utilityFixtures: Fixtures<UtilityTestFixtures, UtilityWorkerFixtures> = {
const csiListener: ClientInstrumentationListener = {
onApiCallBegin: (data, channel) => {
const testInfo = globals.currentTestInfo();
// Some special calls do not get into steps.
if (!testInfo || data.apiName.includes('setTestIdAttribute') || data.apiName === 'tracing.groupEnd')
if (!testInfo)
return;
if (channel.type === 'Tracing' && channel.method === 'tracingGroupEnd') {
// The "tracing.group" step ends together with the "tracing.groupEnd" call.
data.userData = (error?: Error) => tracingGroupSteps.pop()?.complete({ error });
return;
}
const zone = currentZone().data<TestStepInternal>('stepZone');
const isExpectCall = data.apiName === 'locator._expect' || data.apiName === 'frame._expect' || data.apiName === 'page._expectScreenshot';
const isExpectCall = (channel.type === 'Frame' && channel.method === 'expect') || (channel.type === 'Page' && channel.method === 'expectScreenshot');
if (zone && zone.category === 'expect' && isExpectCall) {
// Display the internal locator._expect call under the name of the enclosing expect call,
// and connect it to the existing expect step.
if (zone.apiName)
data.apiName = zone.apiName;
if (zone.shortTitle || zone.title)
data.title = zone.shortTitle ?? zone.title;
data.stepId = zone.stepId;
return;
}
Expand All @@ -118,27 +116,20 @@ const utilityFixtures: Fixtures<UtilityTestFixtures, UtilityWorkerFixtures> = {
location: data.frames[0],
category: 'pw:api',
title: renderTitle(channel.type, channel.method, channel.params, data.title),
apiName: data.apiName,
params: channel.params,
group: getActionGroup({ type: channel.type, method: channel.method }),
}, tracingGroupSteps[tracingGroupSteps.length - 1]);
data.userData = step;
data.stepId = step.stepId;
if (data.apiName === 'tracing.group')
if (channel.type === 'Tracing' && channel.method === 'tracingGroup') {
// The step will end later, when the corresponding "tracing.groupEnd" call finishes.
tracingGroupSteps.push(step);
} else {
data.userData = (error?: Error) => step.complete({ error });
}
},
onApiCallEnd: data => {

// "tracing.group" step will end later, when "tracing.groupEnd" finishes.
if (data.apiName === 'tracing.group')
return;
if (data.apiName === 'tracing.groupEnd') {
const step = tracingGroupSteps.pop();
step?.complete({ error: data.error });
return;
}
const step = data.userData;
step?.complete({ error: data.error });
const completeStep = data.userData as ((error?: Error) => void) | undefined;
completeStep?.(data.error);
},
onWillPause: ({ keepTestTimeout }) => {
if (!keepTestTimeout)
Expand Down
Loading
Loading