diff --git a/packages/playwright-core/src/client/channelOwner.ts b/packages/playwright-core/src/client/channelOwner.ts index f6ed9d166f192..e8c139bbb047c 100644 --- a/packages/playwright-core/src/client/channelOwner.ts +++ b/packages/playwright-core/src/client/channelOwner.ts @@ -193,7 +193,10 @@ export abstract class ChannelOwner await func(apiZone)); diff --git a/packages/playwright-core/src/client/clientInstrumentation.ts b/packages/playwright-core/src/client/clientInstrumentation.ts index 098ca06abdee1..25e05257ace72 100644 --- a/packages/playwright-core/src/client/clientInstrumentation.ts +++ b/packages/playwright-core/src/client/clientInstrumentation.ts @@ -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; diff --git a/packages/playwright-core/src/client/frame.ts b/packages/playwright-core/src/client/frame.ts index a7789601c1b5e..46b509616e749 100644 --- a/packages/playwright-core/src/client/frame.ts +++ b/packages/playwright-core/src/client/frame.ts @@ -514,31 +514,33 @@ export class Frame extends ChannelOwner implements api.Fr return (await this._channel.title({}, kNoTimeout)).value; } - async _expect(expression: string, options: Omit & { timeout: number, signal?: AbortSignal }): Promise { - 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 & { timeout: number, signal?: AbortSignal, title?: string }): Promise { + 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 }); } } diff --git a/packages/playwright-core/src/client/page.ts b/packages/playwright-core/src/client/page.ts index 71982288674e0..8825ec2a9549b 100644 --- a/packages/playwright-core/src/client/page.ts +++ b/packages/playwright-core/src/client/page.ts @@ -84,6 +84,7 @@ export type ExpectScreenshotOptions = Omit implements api.Page { @@ -650,31 +651,33 @@ export class Page extends ChannelOwner 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 { diff --git a/packages/playwright-core/src/client/types.ts b/packages/playwright-core/src/client/types.ts index 81c562a1be535..89a610732afa5 100644 --- a/packages/playwright-core/src/client/types.ts +++ b/packages/playwright-core/src/client/types.ts @@ -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 & { expectedValue?: any, timeout: number, signal?: AbortSignal }; +export type FrameExpectParams = Omit & { expectedValue?: any, timeout: number, signal?: AbortSignal, title?: string }; diff --git a/packages/playwright-core/src/server/fetch.ts b/packages/playwright-core/src/server/fetch.ts index 27a520fea4719..1a9718ef3867c 100644 --- a/packages/playwright-core/src/server/fetch.ts +++ b/packages/playwright-core/src/server/fetch.ts @@ -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'; @@ -342,9 +341,10 @@ export abstract class APIRequestContext extends SdkObject { const resultPromise = new Promise((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(); @@ -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 => { @@ -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); @@ -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); + }); + }; +} diff --git a/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts b/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts index fe39c303c8c56..448ebc238f5e3 100644 --- a/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts +++ b/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts @@ -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'; diff --git a/packages/playwright-core/src/server/transport.ts b/packages/playwright-core/src/server/transport.ts index 36a6eb60cb87e..d333834efc86a 100644 --- a/packages/playwright-core/src/server/transport.ts +++ b/packages/playwright-core/src/server/transport.ts @@ -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'; @@ -89,8 +89,9 @@ export class WebSocketTransport implements ConnectionTransport { fulfill({}); }); transport._ws.on('error', event => { - progress?.log(` ${logUrl} ${event.message}`); - reject(new Error('WebSocket error: ' + event.message)); + const message = flattenAggregateError(event).message; + progress?.log(` ${logUrl} ${message}`); + reject(new Error('WebSocket error: ' + message)); transport._ws.close(); }); transport._ws.on('unexpected-response', (request: ClientRequest, response: IncomingMessage) => { @@ -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 => { diff --git a/packages/playwright-core/src/server/webkit/webview/wvBrowser.ts b/packages/playwright-core/src/server/webkit/webview/wvBrowser.ts index 0e4f37808f245..b2664ad68917a 100644 --- a/packages/playwright-core/src/server/webkit/webview/wvBrowser.ts +++ b/packages/playwright-core/src/server/webkit/webview/wvBrowser.ts @@ -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'; @@ -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, }); diff --git a/packages/playwright/src/index.ts b/packages/playwright/src/index.ts index a7b6455a3aaa3..4933dbc88fd78 100644 --- a/packages/playwright/src/index.ts +++ b/packages/playwright/src/index.ts @@ -97,18 +97,16 @@ const utilityFixtures: Fixtures = { 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('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; } @@ -118,27 +116,20 @@ const utilityFixtures: Fixtures = { 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) diff --git a/packages/playwright/src/matchers/expect.ts b/packages/playwright/src/matchers/expect.ts index f50c37fb74919..751e960ce7511 100644 --- a/packages/playwright/src/matchers/expect.ts +++ b/packages/playwright/src/matchers/expect.ts @@ -101,7 +101,6 @@ interface ExpectStep { export interface ExpectTestInfo { _addStep(data: { category: 'expect'; - apiName: string; title: string; shortTitle: string; params?: Record; @@ -333,14 +332,12 @@ function callMatcherAsStep(matcherName: string, info: ExpectMetaInfo, actual: un const defaultTitle = `${info.poll ? 'poll ' : ''}${info.isSoft ? 'soft ' : ''}${info.isNot ? 'not ' : ''}${matcherName}${suffixes.short || ''}`; const shortTitle = customMessage || `Expect ${escapeWithQuotes(defaultTitle, '"')}`; const longTitle = shortTitle + (suffixes.long || ''); - const apiName = `expect${info.poll ? '.poll ' : ''}${info.isSoft ? '.soft ' : ''}${info.isNot ? '.not' : ''}.${matcherName}${suffixes.short || ''}`; // This looks like it is unnecessary, but it isn't - we need to filter // out all the frames that belong to the test runner from caught runtime errors. const stackFrames = expectConfig().filteredStackTrace(captureRawStack()); const stepData = { category: 'expect' as const, - apiName, title: longTitle, shortTitle, location: stackFrames[0], @@ -370,7 +367,7 @@ function callMatcherAsStep(matcherName: string, info: ExpectMetaInfo, actual: un try { const invoke = () => info.poll ? invokePollMatcher(matcherName, info, matcher, actual, args, promise) - : invokeMatcher(matcherName, info, matcher, actual, args, promise); + : invokeMatcher(matcherName, info, matcher, actual, args, promise, shortTitle); const result = step ? currentZone().with('stepZone', step).run(invoke) : invoke(); if (result instanceof Promise) return result.then(finalizer, handleError); @@ -387,6 +384,7 @@ function invokeMatcher( actual: unknown, args: any[], promise: 'resolves' | 'rejects' | undefined, + title: string, ): MatcherResult | Promise { const isNot = !!info.isNot; const timeout = info.timeout ?? expectConfig().timeout ?? defaultExpectTimeout; @@ -396,6 +394,7 @@ function invokeMatcher( promise: promise ?? '', utils, timeout, + title, equals: throwUnsupportedExpectMatcherError as any, }; diff --git a/packages/playwright/src/matchers/matchers.ts b/packages/playwright/src/matchers/matchers.ts index 0bdf1b3ac7710..2cbe2705d2b5b 100644 --- a/packages/playwright/src/matchers/matchers.ts +++ b/packages/playwright/src/matchers/matchers.ts @@ -42,6 +42,7 @@ import type { URLPattern } from '@isomorphic/urlMatch'; export type ExpectMatcherStateInternal = Omit & { utils: ExpectMatcherUtils & InternalMatcherUtils; + title: string; }; type ExpectedTextValue = { @@ -86,7 +87,7 @@ export function toBeAttached( const expected = attached ? 'attached' : 'detached'; const arg = attached ? '' : '{ attached: false }'; return toBeTruthy.call(this, 'toBeAttached', locator, 'Locator', expected, arg, async (isNot, timeout, signal) => { - return await locator._expect(attached ? 'to.be.attached' : 'to.be.detached', { isNot, timeout, signal }); + return await locator._expect(attached ? 'to.be.attached' : 'to.be.detached', { isNot, timeout, signal, title: this.title }); }, options); } @@ -111,7 +112,7 @@ export function toBeChecked( arg = options?.checked === false ? `{ checked: false }` : ''; } return toBeTruthy.call(this, 'toBeChecked', locator, 'Locator', expected, arg, async (isNot, timeout, signal) => { - return await locator._expect('to.be.checked', { isNot, timeout, expectedValue, signal }); + return await locator._expect('to.be.checked', { isNot, timeout, expectedValue, signal, title: this.title }); }, options); } @@ -121,7 +122,7 @@ export function toBeDisabled( options?: { timeout?: number, signal?: AbortSignal }, ) { return toBeTruthy.call(this, 'toBeDisabled', locator, 'Locator', 'disabled', '', async (isNot, timeout, signal) => { - return await locator._expect('to.be.disabled', { isNot, timeout, signal }); + return await locator._expect('to.be.disabled', { isNot, timeout, signal, title: this.title }); }, options); } @@ -134,7 +135,7 @@ export function toBeEditable( const expected = editable ? 'editable' : 'readOnly'; const arg = editable ? '' : '{ editable: false }'; return toBeTruthy.call(this, 'toBeEditable', locator, 'Locator', expected, arg, async (isNot, timeout, signal) => { - return await locator._expect(editable ? 'to.be.editable' : 'to.be.readonly', { isNot, timeout, signal }); + return await locator._expect(editable ? 'to.be.editable' : 'to.be.readonly', { isNot, timeout, signal, title: this.title }); }, options); } @@ -144,7 +145,7 @@ export function toBeEmpty( options?: { timeout?: number, signal?: AbortSignal }, ) { return toBeTruthy.call(this, 'toBeEmpty', locator, 'Locator', 'empty', '', async (isNot, timeout, signal) => { - return await locator._expect('to.be.empty', { isNot, timeout, signal }); + return await locator._expect('to.be.empty', { isNot, timeout, signal, title: this.title }); }, options); } @@ -157,7 +158,7 @@ export function toBeEnabled( const expected = enabled ? 'enabled' : 'disabled'; const arg = enabled ? '' : '{ enabled: false }'; return toBeTruthy.call(this, 'toBeEnabled', locator, 'Locator', expected, arg, async (isNot, timeout, signal) => { - return await locator._expect(enabled ? 'to.be.enabled' : 'to.be.disabled', { isNot, timeout, signal }); + return await locator._expect(enabled ? 'to.be.enabled' : 'to.be.disabled', { isNot, timeout, signal, title: this.title }); }, options); } @@ -167,7 +168,7 @@ export function toBeFocused( options?: { timeout?: number, signal?: AbortSignal }, ) { return toBeTruthy.call(this, 'toBeFocused', locator, 'Locator', 'focused', '', async (isNot, timeout, signal) => { - return await locator._expect('to.be.focused', { isNot, timeout, signal }); + return await locator._expect('to.be.focused', { isNot, timeout, signal, title: this.title }); }, options); } @@ -177,7 +178,7 @@ export function toBeHidden( options?: { timeout?: number, signal?: AbortSignal }, ) { return toBeTruthy.call(this, 'toBeHidden', locator, 'Locator', 'hidden', '', async (isNot, timeout, signal) => { - return await locator._expect('to.be.hidden', { isNot, timeout, signal }); + return await locator._expect('to.be.hidden', { isNot, timeout, signal, title: this.title }); }, options); } @@ -190,7 +191,7 @@ export function toBeVisible( const expected = visible ? 'visible' : 'hidden'; const arg = visible ? '' : '{ visible: false }'; return toBeTruthy.call(this, 'toBeVisible', locator, 'Locator', expected, arg, async (isNot, timeout, signal) => { - return await locator._expect(visible ? 'to.be.visible' : 'to.be.hidden', { isNot, timeout, signal }); + return await locator._expect(visible ? 'to.be.visible' : 'to.be.hidden', { isNot, timeout, signal, title: this.title }); }, options); } @@ -200,7 +201,7 @@ export function toBeInViewport( options?: { timeout?: number, ratio?: number, signal?: AbortSignal }, ) { return toBeTruthy.call(this, 'toBeInViewport', locator, 'Locator', 'in viewport', '', async (isNot, timeout, signal) => { - return await locator._expect('to.be.in.viewport', { isNot, expectedNumber: options?.ratio, timeout, signal }); + return await locator._expect('to.be.in.viewport', { isNot, expectedNumber: options?.ratio, timeout, signal, title: this.title }); }, options); } @@ -213,12 +214,12 @@ export function toContainText( if (Array.isArray(expected)) { return toEqual.call(this, 'toContainText', locator, 'Locator', async (isNot, timeout, signal) => { const expectedText = serializeExpectedTextValues(expected, { matchSubstring: true, normalizeWhiteSpace: true, ignoreCase: options.ignoreCase }); - return await locator._expect('to.contain.text.array', { expectedText, isNot, useInnerText: options.useInnerText, timeout, signal }); + return await locator._expect('to.contain.text.array', { expectedText, isNot, useInnerText: options.useInnerText, timeout, signal, title: this.title }); }, expected, { ...options, contains: true }); } else { return toMatchText.call(this, 'toContainText', locator, 'Locator', async (isNot, timeout, signal) => { const expectedText = serializeExpectedTextValues([expected], { matchSubstring: true, normalizeWhiteSpace: true, ignoreCase: options.ignoreCase }); - return await locator._expect('to.have.text', { expectedText, isNot, useInnerText: options.useInnerText, timeout, signal }); + return await locator._expect('to.have.text', { expectedText, isNot, useInnerText: options.useInnerText, timeout, signal, title: this.title }); }, expected, { ...options, matchSubstring: true }); } } @@ -231,7 +232,7 @@ export function toHaveAccessibleDescription( ) { return toMatchText.call(this, 'toHaveAccessibleDescription', locator, 'Locator', async (isNot, timeout, signal) => { const expectedText = serializeExpectedTextValues([expected], { ignoreCase: options?.ignoreCase, normalizeWhiteSpace: true }); - return await locator._expect('to.have.accessible.description', { expectedText, isNot, timeout, signal }); + return await locator._expect('to.have.accessible.description', { expectedText, isNot, timeout, signal, title: this.title }); }, expected, options); } @@ -243,7 +244,7 @@ export function toHaveAccessibleName( ) { return toMatchText.call(this, 'toHaveAccessibleName', locator, 'Locator', async (isNot, timeout, signal) => { const expectedText = serializeExpectedTextValues([expected], { ignoreCase: options?.ignoreCase, normalizeWhiteSpace: true }); - return await locator._expect('to.have.accessible.name', { expectedText, isNot, timeout, signal }); + return await locator._expect('to.have.accessible.name', { expectedText, isNot, timeout, signal, title: this.title }); }, expected, options); } @@ -255,7 +256,7 @@ export function toHaveAccessibleErrorMessage( ) { return toMatchText.call(this, 'toHaveAccessibleErrorMessage', locator, 'Locator', async (isNot, timeout, signal) => { const expectedText = serializeExpectedTextValues([expected], { ignoreCase: options?.ignoreCase, normalizeWhiteSpace: true }); - return await locator._expect('to.have.accessible.error.message', { expectedText: expectedText, isNot, timeout, signal }); + return await locator._expect('to.have.accessible.error.message', { expectedText: expectedText, isNot, timeout, signal, title: this.title }); }, expected, options); } @@ -275,12 +276,12 @@ export function toHaveAttribute( } if (expected === undefined) { return toBeTruthy.call(this, 'toHaveAttribute', locator, 'Locator', 'have attribute', '', async (isNot, timeout, signal) => { - return await locator._expect('to.have.attribute', { expressionArg: name, isNot, timeout, signal }); + return await locator._expect('to.have.attribute', { expressionArg: name, isNot, timeout, signal, title: this.title }); }, options); } return toMatchText.call(this, 'toHaveAttribute', locator, 'Locator', async (isNot, timeout, signal) => { const expectedText = serializeExpectedTextValues([expected as (string | RegExp)], { ignoreCase: options?.ignoreCase }); - return await locator._expect('to.have.attribute.value', { expressionArg: name, expectedText, isNot, timeout, signal }); + return await locator._expect('to.have.attribute.value', { expressionArg: name, expectedText, isNot, timeout, signal, title: this.title }); }, expected as (string | RegExp), options); } @@ -293,12 +294,12 @@ export function toHaveClass( if (Array.isArray(expected)) { return toEqual.call(this, 'toHaveClass', locator, 'Locator', async (isNot, timeout, signal) => { const expectedText = serializeExpectedTextValues(expected); - return await locator._expect('to.have.class.array', { expectedText, isNot, timeout, signal }); + return await locator._expect('to.have.class.array', { expectedText, isNot, timeout, signal, title: this.title }); }, expected, options); } else { return toMatchText.call(this, 'toHaveClass', locator, 'Locator', async (isNot, timeout, signal) => { const expectedText = serializeExpectedTextValues([expected]); - return await locator._expect('to.have.class', { expectedText, isNot, timeout, signal }); + return await locator._expect('to.have.class', { expectedText, isNot, timeout, signal, title: this.title }); }, expected, options); } } @@ -314,14 +315,14 @@ export function toContainClass( throw new Error(`"expected" argument in toContainClass cannot contain RegExp values`); return toEqual.call(this, 'toContainClass', locator, 'Locator', async (isNot, timeout, signal) => { const expectedText = serializeExpectedTextValues(expected); - return await locator._expect('to.contain.class.array', { expectedText, isNot, timeout, signal }); + return await locator._expect('to.contain.class.array', { expectedText, isNot, timeout, signal, title: this.title }); }, expected, options); } else { if (isRegExp(expected)) throw new Error(`"expected" argument in toContainClass cannot be a RegExp value`); return toMatchText.call(this, 'toContainClass', locator, 'Locator', async (isNot, timeout, signal) => { const expectedText = serializeExpectedTextValues([expected]); - return await locator._expect('to.contain.class', { expectedText, isNot, timeout, signal }); + return await locator._expect('to.contain.class', { expectedText, isNot, timeout, signal, title: this.title }); }, expected, options); } } @@ -333,7 +334,7 @@ export function toHaveCount( options?: { timeout?: number, signal?: AbortSignal }, ) { return toEqual.call(this, 'toHaveCount', locator, 'Locator', async (isNot, timeout, signal) => { - return await locator._expect('to.have.count', { expectedNumber: expected, isNot, timeout, signal }); + return await locator._expect('to.have.count', { expectedNumber: expected, isNot, timeout, signal, title: this.title }); }, expected, options); } @@ -347,7 +348,7 @@ export function toHaveCSS( ) { return toMatchText.call(this, 'toHaveCSS', locator, 'Locator', async (isNot, timeout, signal) => { const expectedText = serializeExpectedTextValues([expected]); - return await locator._expect('to.have.css', { expressionArg: name, expectedText, isNot, pseudo: options?.pseudo, timeout, signal }); + return await locator._expect('to.have.css', { expressionArg: name, expectedText, isNot, pseudo: options?.pseudo, timeout, signal, title: this.title }); }, expected, options); } @@ -359,7 +360,7 @@ export function toHaveId( ) { return toMatchText.call(this, 'toHaveId', locator, 'Locator', async (isNot, timeout, signal) => { const expectedText = serializeExpectedTextValues([expected]); - return await locator._expect('to.have.id', { expectedText, isNot, timeout, signal }); + return await locator._expect('to.have.id', { expectedText, isNot, timeout, signal, title: this.title }); }, expected, options); } @@ -371,7 +372,7 @@ export function toHaveJSProperty( options?: { timeout?: number, signal?: AbortSignal }, ) { return toEqual.call(this, 'toHaveJSProperty', locator, 'Locator', async (isNot, timeout, signal) => { - return await locator._expect('to.have.property', { expressionArg: name, expectedValue: expected, isNot, timeout, signal }); + return await locator._expect('to.have.property', { expressionArg: name, expectedValue: expected, isNot, timeout, signal, title: this.title }); }, expected, options); } @@ -385,7 +386,7 @@ export function toHaveRole( throw new Error(`"role" argument in toHaveRole must be a string`); return toMatchText.call(this, 'toHaveRole', locator, 'Locator', async (isNot, timeout, signal) => { const expectedText = serializeExpectedTextValues([expected]); - return await locator._expect('to.have.role', { expectedText, isNot, timeout, signal }); + return await locator._expect('to.have.role', { expectedText, isNot, timeout, signal, title: this.title }); }, expected, options); } @@ -398,12 +399,12 @@ export function toHaveText( if (Array.isArray(expected)) { return toEqual.call(this, 'toHaveText', locator, 'Locator', async (isNot, timeout, signal) => { const expectedText = serializeExpectedTextValues(expected, { normalizeWhiteSpace: true, ignoreCase: options.ignoreCase }); - return await locator._expect('to.have.text.array', { expectedText, isNot, useInnerText: options?.useInnerText, timeout, signal }); + return await locator._expect('to.have.text.array', { expectedText, isNot, useInnerText: options?.useInnerText, timeout, signal, title: this.title }); }, expected, options); } else { return toMatchText.call(this, 'toHaveText', locator, 'Locator', async (isNot, timeout, signal) => { const expectedText = serializeExpectedTextValues([expected], { normalizeWhiteSpace: true, ignoreCase: options.ignoreCase }); - return await locator._expect('to.have.text', { expectedText, isNot, useInnerText: options?.useInnerText, timeout, signal }); + return await locator._expect('to.have.text', { expectedText, isNot, useInnerText: options?.useInnerText, timeout, signal, title: this.title }); }, expected, options); } } @@ -416,7 +417,7 @@ export function toHaveValue( ) { return toMatchText.call(this, 'toHaveValue', locator, 'Locator', async (isNot, timeout, signal) => { const expectedText = serializeExpectedTextValues([expected]); - return await locator._expect('to.have.value', { expectedText, isNot, timeout, signal }); + return await locator._expect('to.have.value', { expectedText, isNot, timeout, signal, title: this.title }); }, expected, options); } @@ -428,7 +429,7 @@ export function toHaveValues( ) { return toEqual.call(this, 'toHaveValues', locator, 'Locator', async (isNot, timeout, signal) => { const expectedText = serializeExpectedTextValues(expected); - return await locator._expect('to.have.values', { expectedText, isNot, timeout, signal }); + return await locator._expect('to.have.values', { expectedText, isNot, timeout, signal, title: this.title }); }, expected, options); } @@ -440,7 +441,7 @@ export function toHaveTitle( ) { return toMatchText.call(this, 'toHaveTitle', page, 'Page', async (isNot, timeout, signal) => { const expectedText = serializeExpectedTextValues([expected], { normalizeWhiteSpace: true }); - return await (page.mainFrame() as FrameEx)._expect('to.have.title', { expectedText, isNot, timeout, signal }); + return await (page.mainFrame() as FrameEx)._expect('to.have.title', { expectedText, isNot, timeout, signal, title: this.title }); }, expected, options); } @@ -461,7 +462,7 @@ export function toHaveURL( expected = typeof expected === 'string' ? constructURLBasedOnBaseURL(baseURL, expected) : expected; return toMatchText.call(this, 'toHaveURL', page, 'Page', async (isNot, timeout, signal) => { const expectedText = serializeExpectedTextValues([expected], { ignoreCase: options?.ignoreCase }); - return await (page.mainFrame() as FrameEx)._expect('to.have.url', { expectedText, isNot, timeout, signal }); + return await (page.mainFrame() as FrameEx)._expect('to.have.url', { expectedText, isNot, timeout, signal, title: this.title }); }, expected, options); } diff --git a/packages/playwright/src/matchers/toMatchAriaSnapshot.ts b/packages/playwright/src/matchers/toMatchAriaSnapshot.ts index ab5b13ea0ee9e..d4d5c2a94100c 100644 --- a/packages/playwright/src/matchers/toMatchAriaSnapshot.ts +++ b/packages/playwright/src/matchers/toMatchAriaSnapshot.ts @@ -91,7 +91,7 @@ export async function toMatchAriaSnapshot( if (globalChildren && !expected.match(/^- \/children:/m)) expected = `- /children: ${globalChildren}\n` + expected; - const expectParams = { expectedValue: expected, isNot: this.isNot, timeout, signal: options.signal }; + const expectParams = { expectedValue: expected, isNot: this.isNot, timeout, signal: options.signal, title: this.title }; const { matches: pass, received, log, timedOut, errorMessage } = locator ? await (locator as LocatorEx)._expect('to.match.aria', expectParams) : await ((receiver as Page).mainFrame() as FrameEx)._expect('to.match.aria', expectParams); diff --git a/packages/playwright/src/matchers/toMatchSnapshot.ts b/packages/playwright/src/matchers/toMatchSnapshot.ts index 2b79dec0674c4..95dec3bf30407 100644 --- a/packages/playwright/src/matchers/toMatchSnapshot.ts +++ b/packages/playwright/src/matchers/toMatchSnapshot.ts @@ -365,6 +365,7 @@ export async function toHaveScreenshot( isNot: !!this.isNot, timeout, signal: helper.options.signal, + title: this.title, type: screenshotType, comparator: helper.options.comparator, maxDiffPixels: helper.options.maxDiffPixels, diff --git a/packages/playwright/src/worker/testInfo.ts b/packages/playwright/src/worker/testInfo.ts index 3fce56e1b7e3e..d7d107cd844f0 100644 --- a/packages/playwright/src/worker/testInfo.ts +++ b/packages/playwright/src/worker/testInfo.ts @@ -44,7 +44,6 @@ interface TestStepData { shortTitle?: string; category: TestStepCategory; location?: Location; - apiName?: string; params?: Record; box?: boolean; // steps with any defined group are hidden from the report diff --git a/packages/utils/happyEyeballs.ts b/packages/utils/happyEyeballs.ts deleted file mode 100644 index 7bd585d069327..0000000000000 --- a/packages/utils/happyEyeballs.ts +++ /dev/null @@ -1,217 +0,0 @@ -/** - * 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 dns from 'dns'; -import http from 'http'; -import https from 'https'; -import net from 'net'; -import stream from 'stream'; -import tls from 'tls'; - -import { assert } from '@isomorphic/assert'; -import { ManualPromise } from '@isomorphic/manualPromise'; -import { monotonicTime } from '@isomorphic/time'; - -// Implementation(partial) of Happy Eyeballs 2 algorithm described in -// https://www.rfc-editor.org/rfc/rfc8305 - -// Same as in Chromium (https://source.chromium.org/chromium/chromium/src/+/5666ff4f5077a7e2f72902f3a95f5d553ea0d88d:net/socket/transport_connect_job.cc;l=102) -const connectionAttemptDelayMs = 300; - -const kDNSLookupAt = Symbol('kDNSLookupAt'); -const kTCPConnectionAt = Symbol('kTCPConnectionAt'); - -class HttpHappyEyeballsAgent extends http.Agent { - override createConnection(options: http.ClientRequestArgs, oncreate?: (err: Error | null, socket: stream.Duplex) => void): stream.Duplex | undefined { - // There is no ambiguity in case of IP address. - if (net.isIP(clientRequestArgsToHostName(options))) - return net.createConnection(options as net.NetConnectOpts); - const cb = oncreate as unknown as ((err: Error | null, socket?: net.Socket) => void) | undefined; - createConnectionAsync(options, cb, /* useTLS */ false).catch(err => cb?.(err)); - } -} - -class HttpsHappyEyeballsAgent extends https.Agent { - override createConnection(options: http.ClientRequestArgs, oncreate?: (err: Error | null, socket: stream.Duplex) => void): stream.Duplex | undefined { - // There is no ambiguity in case of IP address. - if (net.isIP(clientRequestArgsToHostName(options))) - return tls.connect(options as tls.ConnectionOptions); - const cb = oncreate as unknown as ((err: Error | null, socket?: tls.TLSSocket) => void) | undefined; - createConnectionAsync(options, cb, /* useTLS */ true).catch(err => cb?.(err)); - } -} - -// These options are aligned with the default Node.js globalAgent options. -export const httpsHappyEyeballsAgent = new HttpsHappyEyeballsAgent({ keepAlive: true }); -export const httpHappyEyeballsAgent = new HttpHappyEyeballsAgent({ keepAlive: true }); - -export async function createSocket(host: string, port: number): Promise { - return new Promise((resolve, reject) => { - if (net.isIP(host)) { - const socket = net.createConnection({ host, port }); - socket.on('connect', () => resolve(socket)); - socket.on('error', error => reject(error)); - } else { - createConnectionAsync({ host, port }, (err, socket) => { - if (err) - reject(err); - if (socket) - resolve(socket); - }, /* useTLS */ false).catch(err => reject(err)); - } - }); -} - -export async function createTLSSocket(options: tls.ConnectionOptions): Promise { - return new Promise((resolve, reject) => { - assert(options.host, 'host is required'); - if (net.isIP(options.host)) { - const socket = tls.connect(options); - socket.on('secureConnect', () => resolve(socket)); - socket.on('error', error => reject(error)); - } else { - createConnectionAsync(options, (err, socket) => { - if (err) - reject(err); - if (socket) { - socket.on('secureConnect', () => resolve(socket)); - socket.on('error', error => reject(error)); - } - }, true).catch(err => reject(err)); - } - }); -} - -export async function createConnectionAsync( - options: http.ClientRequestArgs, - oncreate: ((err: Error | null, socket?: tls.TLSSocket) => void) | undefined, - useTLS: true -): Promise; - -export async function createConnectionAsync( - options: http.ClientRequestArgs, - oncreate: ((err: Error | null, socket?: net.Socket) => void) | undefined, - useTLS: false -): Promise; - -export async function createConnectionAsync( - options: http.ClientRequestArgs, - oncreate: ((err: Error | null, socket?: any) => void) | undefined, - useTLS: boolean -): Promise { - const lookup = (options as any).__testHookLookup || lookupAddresses; - const hostname = clientRequestArgsToHostName(options); - const addresses = await lookup(hostname); - const dnsLookupAt = monotonicTime(); - const sockets = new Set(); - let firstError; - let errorCount = 0; - const handleError = (socket: net.Socket, err: Error) => { - if (!sockets.delete(socket)) - return; - ++errorCount; - firstError ??= err; - if (errorCount === addresses.length) - oncreate?.(firstError); - }; - - const connected = new ManualPromise(); - for (const { address } of addresses) { - const socket = useTLS ? - tls.connect({ - ...(options as tls.ConnectionOptions), - port: options.port as number, - host: address, - servername: hostname }) : - net.createConnection({ - ...options, - port: options.port as number, - host: address }); - - (socket as any)[kDNSLookupAt] = dnsLookupAt; - - // Each socket may fire only one of 'connect', 'timeout' or 'error' events. - // None of these events are fired after socket.destroy() is called. - socket.on('connect', () => { - (socket as any)[kTCPConnectionAt] = monotonicTime(); - - connected.resolve(); - oncreate?.(null, socket); - // TODO: Cache the result? - // Close other outstanding sockets. - sockets.delete(socket); - for (const s of sockets) - s.destroy(); - sockets.clear(); - }); - socket.on('timeout', () => { - // Timeout is not an error, so we have to manually close the socket. - socket.destroy(); - handleError(socket, new Error('Connection timeout')); - }); - socket.on('error', e => handleError(socket, e)); - sockets.add(socket); - await Promise.race([ - connected, - new Promise(f => setTimeout(f, connectionAttemptDelayMs)) - ]); - if (connected.isDone()) - break; - } -} - -async function lookupAddresses(hostname: string): Promise { - // Use separate family lookups to avoid AI_ADDRCONFIG filtering. When family: 0 is used, - // Node.js passes AI_ADDRCONFIG to getaddrinfo(), which on macOS can filter out addresses - // for a family that has no non-loopback interface — e.g. returning only 127.0.0.1 for - // "localhost" when IPv6 is not available on non-loopback interfaces, even though ::1 is - // present in /etc/hosts. Separate family: 4 and family: 6 lookups do not pass AI_ADDRCONFIG. - const [v4Result, v6Result] = await Promise.allSettled([ - dns.promises.lookup(hostname, { all: true, family: 4 }), - dns.promises.lookup(hostname, { all: true, family: 6 }), - ]); - const v4Addresses = v4Result.status === 'fulfilled' ? v4Result.value : []; - const v6Addresses = v6Result.status === 'fulfilled' ? v6Result.value : []; - if (!v4Addresses.length && !v6Addresses.length) { - if (v4Result.status === 'rejected') - throw v4Result.reason; - throw (v6Result as PromiseRejectedResult).reason; - } - const result: dns.LookupAddress[] = []; - // Alternate IPv6 and IPv4 addresses per RFC 8305 (prefer IPv6 first). - for (let i = 0; i < Math.max(v4Addresses.length, v6Addresses.length); i++) { - if (v6Addresses[i]) - result.push(v6Addresses[i]); - if (v4Addresses[i]) - result.push(v4Addresses[i]); - } - return result; -} - -function clientRequestArgsToHostName(options: http.ClientRequestArgs): string { - if (options.hostname) - return options.hostname; - if (options.host) - return options.host; - throw new Error('Either options.hostname or options.host must be provided'); -} - -export function timingForSocket(socket: net.Socket | tls.TLSSocket) { - return { - dnsLookupAt: (socket as any)[kDNSLookupAt] as number | undefined, - tcpConnectionAt: (socket as any)[kTCPConnectionAt] as number | undefined, - }; -} diff --git a/packages/utils/network.ts b/packages/utils/network.ts index 3b3790754c0ba..9205f225fe806 100644 --- a/packages/utils/network.ts +++ b/packages/utils/network.ts @@ -15,17 +15,17 @@ */ +import dns from 'dns'; import http from 'http'; import http2 from 'http2'; import https from 'https'; +import net from 'net'; import { HttpsProxyAgent } from 'https-proxy-agent'; import { SocksProxyAgent } from 'socks-proxy-agent'; import { getProxyForUrl } from 'proxy-from-env'; import { ManualPromise } from '@isomorphic/manualPromise'; -import { httpHappyEyeballsAgent, httpsHappyEyeballsAgent } from './happyEyeballs'; - -import type net from 'net'; +import { rewriteErrorMessage } from './stackTrace'; export type ProxySettings = { server: string, @@ -50,6 +50,7 @@ export function httpRequest(params: HTTPRequestParams, onResponse: (r: http.Inco const options: https.RequestOptions = { method: params.method || 'GET', headers: params.headers, + lookup: dualStackLookup, }; if (params.rejectUnauthorized !== undefined) options.rejectUnauthorized = params.rejectUnauthorized; @@ -69,8 +70,6 @@ export function httpRequest(params: HTTPRequestParams, onResponse: (r: http.Inco } } - options.agent ??= url.protocol === 'https:' ? httpsHappyEyeballsAgent : httpHappyEyeballsAgent; - let cancelRequest: (e: Error | undefined) => void; const requestCallback = (res: http.IncomingMessage) => { const statusCode = res.statusCode || 0; @@ -86,7 +85,7 @@ export function httpRequest(params: HTTPRequestParams, onResponse: (r: http.Inco const request = url.protocol === 'https:' ? https.request(url, options, requestCallback) : http.request(url, options, requestCallback); - request.on('error', onError); + request.on('error', error => onError(flattenAggregateError(error))); if (params.socketTimeout !== undefined) { request.setTimeout(params.socketTimeout, () => { onError(new Error(`Request to ${params.url} timed out after ${params.socketTimeout}ms`)); @@ -156,6 +155,52 @@ export function createProxyAgent(proxy?: ProxySettings, forUrl?: URL) { return new HttpsProxyAgent(proxyURL); } +// Node.js family-agnostic lookup passes AI_ADDRCONFIG to getaddrinfo(), which can filter out +// addresses of a family that has no non-loopback interface, and "localhost" may miss addresses +// of a family that is not listed in /etc/hosts — e.g. resolving to only 127.0.0.1 even though +// ::1 is served. Separate family: 4 and family: 6 lookups do not have these problems. Native +// Happy Eyeballs (autoSelectFamily) then races connection attempts across the families. +export const dualStackLookup: net.LookupFunction = (hostname, options, callback) => { + const families = options.family === 4 || options.family === 6 ? [options.family] : [6, 4]; + void Promise.allSettled(families.map(family => dns.promises.lookup(hostname, { all: true, family }))).then(results => { + const perFamily = results.map(result => result.status === 'fulfilled' ? result.value : []); + const addresses: dns.LookupAddress[] = []; + // Alternate IPv6 and IPv4 addresses per RFC 8305 (prefer IPv6 first). + for (let i = 0; i < Math.max(...perFamily.map(list => list.length)); i++) { + for (const list of perFamily) { + if (list[i]) + addresses.push(list[i]); + } + } + if (!addresses.length) { + const firstError = results.map(result => result.status === 'rejected' ? result.reason : undefined).find(Boolean); + callback(firstError ?? new Error(`Cannot resolve address for ${hostname}`), ''); + return; + } + if (options.all) + callback(null, addresses); + else + callback(null, addresses[0].address, addresses[0].family); + }); +}; + +// When every raced connection attempt fails, Node.js reports an AggregateError with an +// empty message and the individual failures in the `errors` property. Surface those instead. +export function flattenAggregateError(error: Error): Error { + const errors = (error as any).errors as Error[] | undefined; + if (error.name === 'AggregateError' && !error.message && errors?.length) + return rewriteErrorMessage(error, errors.map(e => e.message).join('\n')); + return error; +} + +export async function createSocket(host: string, port: number): Promise { + return new Promise((resolve, reject) => { + const socket = net.createConnection({ host, port, lookup: dualStackLookup }); + socket.on('connect', () => resolve(socket)); + socket.on('error', error => reject(flattenAggregateError(error))); + }); +} + export function createHttpServer(requestListener?: (req: http.IncomingMessage, res: http.ServerResponse) => void): http.Server; export function createHttpServer(options: http.ServerOptions, requestListener?: (req: http.IncomingMessage, res: http.ServerResponse) => void): http.Server; export function createHttpServer(...args: any[]): http.Server { diff --git a/packages/utils/socksProxy.ts b/packages/utils/socksProxy.ts index db629b4a12989..c1002dadb6e13 100644 --- a/packages/utils/socksProxy.ts +++ b/packages/utils/socksProxy.ts @@ -20,7 +20,7 @@ import net from 'net'; import { assert } from '@isomorphic/assert'; import { createGuid } from './crypto'; import { debugLogger } from './debugLogger'; -import { createSocket } from './happyEyeballs'; +import { createSocket } from './network'; import type { AddressInfo } from 'net'; diff --git a/tests/installation/playwright-packages-install-behavior.spec.ts b/tests/installation/playwright-packages-install-behavior.spec.ts index 2474d889ebb59..b9b658713ad95 100755 --- a/tests/installation/playwright-packages-install-behavior.spec.ts +++ b/tests/installation/playwright-packages-install-behavior.spec.ts @@ -114,6 +114,6 @@ test('@playwright/test should work', async ({ exec, checkInstalledSoftwareOnDisk expect(result3).toContain('3 passed'); const result4 = await exec('npx playwright test -c . failing.spec.js', { expectToExitWithError: true, env: { DEBUG: 'pw:api' } }); - expect(result4).toContain('expect.toHaveText started'); + expect(result4).toContain('Expect "toHaveText" started'); expect(result4).toContain('failing.spec.js:5:38'); }); diff --git a/tests/library/browsercontext-basic.spec.ts b/tests/library/browsercontext-basic.spec.ts index 2d5debad68f31..662919a375026 100644 --- a/tests/library/browsercontext-basic.spec.ts +++ b/tests/library/browsercontext-basic.spec.ts @@ -366,6 +366,38 @@ it('should emulate navigator.onLine', async ({ browser, server }) => { await context.close(); }); +it('should emulate navigator.onLine across navigations', { + annotation: [ + { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/42174' }, + { type: 'issue', description: 'https://issues.chromium.org/issues/544795254' }, + ], +}, async ({ browser, server, browserName }) => { + it.fixme(browserName === 'chromium', 'does not survive cross-process navgiation'); + + const context = await browser.newContext(); + const page = await context.newPage(); + await page.goto(server.EMPTY_PAGE); + expect(await page.evaluate(() => window.navigator.onLine)).toBe(true); + await context.setOffline(true); + expect(await page.evaluate(() => window.navigator.onLine)).toBe(false); + + await page.goto('about:blank'); + expect(await page.evaluate(() => window.navigator.onLine)).toBe(false); + await page.goto('data:text/html,offline'); + expect(await page.evaluate(() => window.navigator.onLine)).toBe(false); + + if (browserName === 'chromium') { + // Try a cross-process navigation in Chromium, which allows routing while offline. + await page.route('**/*', route => route.fulfill({ contentType: 'text/html', body: '' })); + await page.goto(server.CROSS_PROCESS_PREFIX + '/empty.html'); + expect(await page.evaluate(() => window.navigator.onLine)).toBe(false); + } + + await context.setOffline(false); + expect(await page.evaluate(() => window.navigator.onLine)).toBe(true); + await context.close(); +}); + it('should emulate offline event', { annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/37295' } }, async ({ browser }) => { const context = await browser.newContext(); const page = await context.newPage(); diff --git a/tests/library/browsercontext-pages.spec.ts b/tests/library/browsercontext-pages.spec.ts index 15323f9370e1a..2d849ecaa92de 100644 --- a/tests/library/browsercontext-pages.spec.ts +++ b/tests/library/browsercontext-pages.spec.ts @@ -139,6 +139,23 @@ it('should not leak listeners during navigation of 20 pages', async ({ contextFa expect(warning).toBe(null); }); +it('should close page while a reload is committing', async ({ context, browserName }) => { + it.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/42068' }); + it.info().annotations.push({ type: 'issue', description: 'https://issues.chromium.org/issues/536385539' }); + it.fixme(browserName === 'chromium', 'Chromium loses the close when the reload commits into a new RenderFrameHost; fix is not rolled yet'); + // The evaluate never settles on its own, so it only rejects once the reload + // commits. Closing right after that races the commit. Repeat a few times, + // since whether the close or the commit wins is timing-dependent. + for (let i = 0; i < 10; i++) { + const page = await context.newPage(); + await expect(page.evaluate(() => { + location.reload(); + return new Promise(() => {}); + })).rejects.toThrow(/navigation/); + await page.close(); + } +}); + it('should keep selection in multiple pages', async ({ context }) => { it.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/27475' }); const page1 = await context.newPage();