From c8fc23bb1254432b911f2021c03c23932c8a37b8 Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Fri, 5 Jun 2026 14:52:57 +0200 Subject: [PATCH 1/5] fix(mcp): keep remoteHeaders working for remote browser endpoint (#41156) --- .../src/tools/mcp/browserFactory.ts | 7 +++++-- .../playwright-core/src/tools/mcp/config.ts | 7 +++++++ .../playwright-core/src/tools/mcp/program.ts | 1 + tests/mcp/remote-endpoint.spec.ts | 20 +++++++++++++++++++ 4 files changed, 33 insertions(+), 2 deletions(-) diff --git a/packages/playwright-core/src/tools/mcp/browserFactory.ts b/packages/playwright-core/src/tools/mcp/browserFactory.ts index 4ea7e58372bd2..e6b808459c5ac 100644 --- a/packages/playwright-core/src/tools/mcp/browserFactory.ts +++ b/packages/playwright-core/src/tools/mcp/browserFactory.ts @@ -120,9 +120,12 @@ async function createRemoteBrowser(config: FullConfig): Promise // `timeout`. Normalize once so the rest of the function deals with a single // shape. const remote = config.browser.remoteEndpoint!; + // `remoteHeaders` is for back-compat, `remoteEndpoint.headers` takes precedence. + // eslint-disable-next-line no-restricted-syntax + const remoteHeaders = (config.browser as any).remoteHeaders as Record | undefined; const remoteOptions = typeof remote === 'string' - ? { endpoint: remote } - : remote; + ? { endpoint: remote, headers: remoteHeaders } + : { ...remote, headers: { ...remoteHeaders, ...remote.headers } }; const descriptor = await serverRegistry.find(remoteOptions.endpoint); if (descriptor) { diff --git a/packages/playwright-core/src/tools/mcp/config.ts b/packages/playwright-core/src/tools/mcp/config.ts index 2583e2684d8ce..c90c1cf36c9e9 100644 --- a/packages/playwright-core/src/tools/mcp/config.ts +++ b/packages/playwright-core/src/tools/mcp/config.ts @@ -64,6 +64,7 @@ export type CLIOptions = { port?: number; proxyBypass?: string; proxyServer?: string; + remoteHeader?: Record; saveSession?: boolean; secrets?: Record; sharedBrowserContext?: boolean; @@ -364,6 +365,11 @@ function configFromCLIOptions(cliOptions: CLIOptions): Config & { configFile?: s }, }; + // `remoteHeaders` is for back-compat, assign it here so it survives config merging. + if (cliOptions.remoteHeader) + // eslint-disable-next-line no-restricted-syntax + (config.browser as any).remoteHeaders = cliOptions.remoteHeader; + return { ...config, configFile: cliOptions.config }; } @@ -405,6 +411,7 @@ export function configFromEnv(env?: NodeJS.ProcessEnv): Config & { configFile?: options.port = numberParser(e.PLAYWRIGHT_MCP_PORT); options.proxyBypass = envToString(e.PLAYWRIGHT_MCP_PROXY_BYPASS); options.proxyServer = envToString(e.PLAYWRIGHT_MCP_PROXY_SERVER); + options.remoteHeader = headerParser(envToString(e.PLAYWRIGHT_MCP_REMOTE_HEADERS)); options.secrets = dotenvFileLoader(e.PLAYWRIGHT_MCP_SECRETS_FILE); options.storageState = envToString(e.PLAYWRIGHT_MCP_STORAGE_STATE); options.testIdAttribute = envToString(e.PLAYWRIGHT_MCP_TEST_ID_ATTRIBUTE); diff --git a/packages/playwright-core/src/tools/mcp/program.ts b/packages/playwright-core/src/tools/mcp/program.ts index f42c1b0dcc38c..06de1755de454 100644 --- a/packages/playwright-core/src/tools/mcp/program.ts +++ b/packages/playwright-core/src/tools/mcp/program.ts @@ -64,6 +64,7 @@ export function decorateMCPCommand(command: Command) { .option('--port ', 'port to listen on for SSE transport.') .option('--proxy-bypass ', 'comma-separated domains to bypass proxy, for example ".com,chromium.org,.domain.com"') .option('--proxy-server ', 'specify proxy server, for example "http://myproxy:3128" or "socks5://myproxy:8080"') + .addOption(new ProgramOption('--remote-header ', 'headers to send with the remote endpoint connect request, multiple can be specified.').argParser(headerParser).hideHelp()) .option('--sandbox', 'enable the sandbox for all process types that are normally not sandboxed.') .option('--save-session', 'Whether to save the Playwright MCP session into the output directory.') .option('--secrets ', 'path to a file containing secrets in the dotenv format', dotenvFileLoader) diff --git a/tests/mcp/remote-endpoint.spec.ts b/tests/mcp/remote-endpoint.spec.ts index 2a19e737543e6..42521074bbfe8 100644 --- a/tests/mcp/remote-endpoint.spec.ts +++ b/tests/mcp/remote-endpoint.spec.ts @@ -59,3 +59,23 @@ test('remoteEndpoint accepts ConnectOptions object with headers', async ({ start page: expect.stringContaining('Page Title: Title'), }); }); + +test('back-compat: remoteHeaders config still selects the browser on run-server endpoint', async ({ startClient, server, runServerEndpoint }) => { + const { client } = await startClient({ + config: { + browser: { + remoteEndpoint: runServerEndpoint, + remoteHeaders: { 'x-playwright-browser': 'chromium' }, + isolated: true, + }, + } as any, + }); + + const response = await client.callTool({ + name: 'browser_navigate', + arguments: { url: server.HELLO_WORLD }, + }); + expect(response).toHaveResponse({ + page: expect.stringContaining('Page Title: Title'), + }); +}); From 7cc4a85b3728ac07a02c6232f238ad158142940f Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Fri, 5 Jun 2026 15:00:25 +0100 Subject: [PATCH 2/5] chore: throw on Frame.expect / Page.expectScreenshot failure (#40801) --- packages/injected/src/injectedScript.ts | 1 - .../playwright-core/src/client/connection.ts | 8 +- packages/playwright-core/src/client/errors.ts | 30 +++---- packages/playwright-core/src/client/frame.ts | 30 ++++--- packages/playwright-core/src/client/page.ts | 22 +++-- .../playwright-core/src/protocol/validator.ts | 12 +-- .../src/protocol/validatorPrimitives.ts | 4 +- .../src/server/dispatchers/dispatcher.ts | 5 +- .../src/server/dispatchers/frameDispatcher.ts | 27 ++----- packages/playwright-core/src/server/frames.ts | 74 ++++++++++------- packages/playwright-core/src/server/page.ts | 27 ++++--- .../playwright-core/src/server/recorder.ts | 9 ++- packages/protocol/spec/frame.yml | 8 +- packages/protocol/spec/page.yml | 4 +- packages/protocol/src/channels.d.ts | 12 +-- tests/library/inspector/pause.spec.ts | 21 +++++ tests/library/trace-viewer.spec.ts | 2 - tests/page/to-match-aria-snapshot.spec.ts | 81 ++++++++++++++++--- utils/generate_channels.js | 9 +++ 19 files changed, 251 insertions(+), 135 deletions(-) diff --git a/packages/injected/src/injectedScript.ts b/packages/injected/src/injectedScript.ts index 22b118974a324..c07b844f97b64 100644 --- a/packages/injected/src/injectedScript.ts +++ b/packages/injected/src/injectedScript.ts @@ -48,7 +48,6 @@ import type { Builtins } from './utilityScript'; export type FrameExpectParams = Omit & { expectedValue?: any; - noAutoWaiting?: boolean; }; export type ElementState = 'visible' | 'hidden' | 'enabled' | 'disabled' | 'editable' | 'checked' | 'unchecked' | 'indeterminate' | 'stable'; diff --git a/packages/playwright-core/src/client/connection.ts b/packages/playwright-core/src/client/connection.ts index 4e72253fb1f0d..01c04284bbf1f 100644 --- a/packages/playwright-core/src/client/connection.ts +++ b/packages/playwright-core/src/client/connection.ts @@ -42,7 +42,7 @@ import { Stream } from './stream'; import { Tracing } from './tracing'; import { Worker } from './worker'; import { WritableStream } from './writableStream'; -import { ValidationError, findValidator } from '../protocol/validator'; +import { ValidationError, findValidator, maybeFindValidator } from '../protocol/validator'; import type { ClientInstrumentation } from './clientInstrumentation'; import type { HeadersArray } from './types'; import type { ValidatorContext } from '../protocol/validator'; @@ -214,7 +214,7 @@ export class Connection extends EventEmitter { if (this._closedError) return; - const { id, guid, method, params, result, error, log } = message as any; + const { id, guid, method, params, result, error, errorDetails, log } = message as any; if (id) { if (this._platform.isLogEnabled('channel')) this._platform.log('channel', ' ({ fallThrough: value })) }; } -export function parseError(error: SerializedError): Error { +export function parseError(error: SerializedError): PlaywrightError { if (!error.error) { if (error.value === undefined) throw new Error('Serialized error must have either an error or a value'); return parseSerializedValue(error.value, undefined); } - if (error.error.name === 'TimeoutError') { - const e = new TimeoutError(error.error.message); - e.stack = error.error.stack || ''; - return e; - } - if (error.error.name === 'TargetClosedError') { - const e = new TargetClosedError(error.error.message); - e.stack = error.error.stack || ''; - return e; - } - const e = new Error(error.error.message); + let e: PlaywrightError; + if (error.error.name === 'TimeoutError') + e = new TimeoutError(error.error.message); + else if (error.error.name === 'TargetClosedError') + e = new TargetClosedError(error.error.message); + else + e = Object.assign(new PlaywrightError(error.error.message), { name: error.error.name }); e.stack = error.error.stack || ''; - e.name = error.error.name; return e; } diff --git a/packages/playwright-core/src/client/frame.ts b/packages/playwright-core/src/client/frame.ts index 9f5785c597fc5..bb28e673771f4 100644 --- a/packages/playwright-core/src/client/frame.ts +++ b/packages/playwright-core/src/client/frame.ts @@ -22,6 +22,7 @@ import { EventEmitter } from './eventEmitter'; import { ChannelOwner } from './channelOwner'; import { addSourceUrlToScript } from './clientHelper'; import { ElementHandle, convertInputFiles, convertSelectOptionValues } from './elementHandle'; +import { PlaywrightError } from './errors'; import { Events } from './events'; import { JSHandle, assertMaxArguments, parseResult, serializeArgument } from './jsHandle'; import { FrameLocator, Locator, testIdAttributeName } from './locator'; @@ -493,20 +494,25 @@ export class Frame extends ChannelOwner implements api.Fr async _expect(expression: string, options: Omit): Promise { const params: channels.FrameExpectParams = { expression, ...options, isNot: !!options.isNot }; params.expectedValue = serializeArgument(options.expectedValue); - const channelResult = await this._channel.expect(params); - const result: ExpectResult = { - matches: channelResult.matches, - log: channelResult.log, - timedOut: channelResult.timedOut, - errorMessage: channelResult.errorMessage, - }; - if (channelResult.received !== undefined && channelResult.matches === !!options.isNot) { - result.received = { - value: channelResult.received.value !== undefined ? parseResult(channelResult.received.value) : undefined, - ariaSnapshot: channelResult.received.ariaSnapshot, + try { + await this._channel.expect(params); + return { matches: !params.isNot }; + } catch (e) { + 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, }; } - return result; } } diff --git a/packages/playwright-core/src/client/page.ts b/packages/playwright-core/src/client/page.ts index 64301fca549d0..b45b623083a7f 100644 --- a/packages/playwright-core/src/client/page.ts +++ b/packages/playwright-core/src/client/page.ts @@ -28,7 +28,7 @@ import { Coverage } from './coverage'; import { DisposableObject, DisposableStub } from './disposable'; import { Download } from './download'; import { ElementHandle, determineScreenshotType } from './elementHandle'; -import { TargetClosedError, isTargetClosedError, parseError, serializeError } from './errors'; +import { PlaywrightError, TargetClosedError, isTargetClosedError, parseError, serializeError } from './errors'; import { Events } from './events'; import { FileChooser } from './fileChooser'; import { Frame, verifyLoadState } from './frame'; @@ -627,12 +627,20 @@ export class Page extends ChannelOwner implements api.Page frame: (options.locator as Locator)._frame._channel, selector: (options.locator as Locator)._selector, } : undefined; - return await this._channel.expectScreenshot({ - ...options, - isNot: !!options.isNot, - locator, - mask, - }); + try { + const result = await this._channel.expectScreenshot({ + ...options, + isNot: !!options.isNot, + locator, + mask, + }); + return { actual: result.actual }; + } catch (e) { + if (!(e instanceof PlaywrightError)) + throw e; + const details = e.details as channels.PageExpectScreenshotErrorDetails; + return { ...details, errorMessage: details.customErrorMessage }; + } } async title(): Promise { diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index 656817e780068..bbdc20aebf172 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -1658,15 +1658,14 @@ scheme.FrameExpectParams = tObject({ isNot: tBoolean, timeout: tFloat, }); -scheme.FrameExpectResult = tObject({ - matches: tBoolean, +scheme.FrameExpectResult = tOptional(tObject({})); +scheme.FrameExpectErrorDetails = tObject({ received: tOptional(tObject({ value: tOptional(tType('SerializedValue')), ariaSnapshot: tOptional(tString), })), timedOut: tOptional(tBoolean), - errorMessage: tOptional(tString), - log: tOptional(tArray(tString)), + customErrorMessage: tOptional(tString), }); scheme.JSHandleInitializer = tObject({ preview: tString, @@ -2421,8 +2420,11 @@ scheme.PageExpectScreenshotParams = tObject({ style: tOptional(tString), }); scheme.PageExpectScreenshotResult = tObject({ + actual: tOptional(tBinary), +}); +scheme.PageExpectScreenshotErrorDetails = tObject({ diff: tOptional(tBinary), - errorMessage: tOptional(tString), + customErrorMessage: tOptional(tString), actual: tOptional(tBinary), previous: tOptional(tBinary), timedOut: tOptional(tBoolean), diff --git a/packages/playwright-core/src/protocol/validatorPrimitives.ts b/packages/playwright-core/src/protocol/validatorPrimitives.ts index ba15b856d8ada..551497237ae56 100644 --- a/packages/playwright-core/src/protocol/validatorPrimitives.ts +++ b/packages/playwright-core/src/protocol/validatorPrimitives.ts @@ -23,13 +23,13 @@ export type ValidatorContext = { }; export const scheme: { [key: string]: Validator } = {}; -export function findValidator(type: string, method: string, kind: 'Initializer' | 'Event' | 'Params' | 'Result'): Validator { +export function findValidator(type: string, method: string, kind: 'Initializer' | 'Event' | 'Params' | 'Result' | 'ErrorDetails'): Validator { const validator = maybeFindValidator(type, method, kind); if (!validator) throw new ValidationError(`Unknown scheme for ${kind}: ${type}.${method}`); return validator; } -export function maybeFindValidator(type: string, method: string, kind: 'Initializer' | 'Event' | 'Params' | 'Result'): Validator | undefined { +export function maybeFindValidator(type: string, method: string, kind: 'Initializer' | 'Event' | 'Params' | 'Result' | 'ErrorDetails'): Validator | undefined { const schemeName = type + (kind === 'Initializer' ? '' : method[0].toUpperCase() + method.substring(1)) + kind; return scheme[schemeName]; } diff --git a/packages/playwright-core/src/server/dispatchers/dispatcher.ts b/packages/playwright-core/src/server/dispatchers/dispatcher.ts index 2a75e1d8d3dc8..50460099f42a0 100644 --- a/packages/playwright-core/src/server/dispatchers/dispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/dispatcher.ts @@ -22,7 +22,7 @@ import { isUnderTest } from '@utils/debug'; import { assert } from '@isomorphic/assert'; import { monotonicTime } from '@isomorphic/time'; import { rewriteErrorMessage } from '@isomorphic/stackTrace'; -import { ValidationError, createMetadataValidator, createWaitInfoValidator, findValidator } from '../../protocol/validator'; +import { ValidationError, createMetadataValidator, createWaitInfoValidator, findValidator, maybeFindValidator } from '../../protocol/validator'; import { TargetClosedError, isTargetClosedError, serializeError } from '../errors'; import { createRootSdkObject, SdkObject } from '../instrumentation'; import { isProtocolError } from '../protocolError'; @@ -366,6 +366,9 @@ export class DispatcherConnection { rewriteErrorMessage(e, 'Target crashed ' + e.browserLogMessage()); } response.error = serializeError(e); + const detailsValidator = maybeFindValidator(dispatcher._type, method, 'ErrorDetails'); + if (detailsValidator) + response.errorDetails = detailsValidator((e as any)?.details ?? {}, '', this._validatorToWireContext()); // The command handler could have set error in the metadata, do not reset it if there was no exception. callMetadata.error = response.error; } finally { diff --git a/packages/playwright-core/src/server/dispatchers/frameDispatcher.ts b/packages/playwright-core/src/server/dispatchers/frameDispatcher.ts index 9c038ccc20b4d..8c7af45a22d1c 100644 --- a/packages/playwright-core/src/server/dispatchers/frameDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/frameDispatcher.ts @@ -14,10 +14,8 @@ * limitations under the License. */ -import yaml from 'yaml'; -import { parseAriaSnapshotUnsafe } from '@isomorphic/ariaSnapshot'; import { renderTitleForCall } from '@isomorphic/protocolFormatter'; -import { Frame } from '../frames'; +import { ExpectError, Frame } from '../frames'; import { Dispatcher } from './dispatcher'; import { ElementHandleDispatcher } from './elementHandlerDispatcher'; import { parseArgument, serializeResult } from './jsHandleDispatcher'; @@ -273,23 +271,14 @@ export class FrameDispatcher extends Dispatcher { - let expectedValue = params.expectedValue ? parseArgument(params.expectedValue) : undefined; - if (params.expression === 'to.match.aria' && expectedValue) - expectedValue = parseAriaSnapshotUnsafe(yaml, expectedValue); progress.log(`${renderTitleForCall(progress.metadata)}${params.timeout ? ` with timeout ${params.timeout}ms` : ''}`); - const result = await this._frame.expect(progress, params.selector, { ...params, expectedValue }); - const channelResult: channels.FrameExpectResult = { - matches: result.matches, - log: result.log, - timedOut: result.timedOut, - errorMessage: result.errorMessage, - }; - if (result.received !== undefined) { - channelResult.received = { - value: result.received.value !== undefined ? serializeResult(result.received.value) : undefined, - ariaSnapshot: result.received.ariaSnapshot, - }; + const expectedValue = params.expectedValue ? parseArgument(params.expectedValue) : undefined; + try { + await this._frame.expect(progress, params.selector, { ...params, expectedValue }); + } catch (e) { + if (e instanceof ExpectError && e.details.received && 'value' in e.details.received) + e.details.received.value = serializeResult(e.details.received.value); + throw e; } - return channelResult; } } diff --git a/packages/playwright-core/src/server/frames.ts b/packages/playwright-core/src/server/frames.ts index 92bb6801bc146..48a352bcc1f17 100644 --- a/packages/playwright-core/src/server/frames.ts +++ b/packages/playwright-core/src/server/frames.ts @@ -15,6 +15,8 @@ * limitations under the License. */ +import yaml from 'yaml'; +import { parseAriaSnapshotUnsafe } from '@isomorphic/ariaSnapshot'; import { isInvalidSelectorError } from '@isomorphic/selectorParser'; import { ManualPromise } from '@isomorphic/manualPromise'; import { eventsHelper } from '@utils/eventsHelper'; @@ -37,7 +39,6 @@ import { Page, ariaSnapshotForFrame } from './page'; import { isAbortError, nullProgress, ProgressController } from './progress'; import * as types from './types'; import { isSessionClosedError } from './protocolError'; -import { compressCallLog } from './callLog'; import type { ConsoleMessage } from './console'; import type { SelectorInfo } from './frameSelectors'; @@ -95,8 +96,23 @@ export class NavigationAbortedError extends Error { } } -export type ExpectReceived = { value?: any, ariaSnapshot?: string }; -export type ExpectResult = { matches: boolean, received?: ExpectReceived, log?: string[], timedOut?: boolean, errorMessage?: string }; +type ExpectReceived = { value?: any, ariaSnapshot?: string }; + +type ExpectErrorDetails = { + received?: ExpectReceived; + timedOut?: boolean; + customErrorMessage?: string; +}; + +export class ExpectError extends Error { + readonly details: ExpectErrorDetails; + + constructor(details: ExpectErrorDetails) { + super('Expect failed'); + this.name = 'ExpectError'; + this.details = details; + } +} const kDummyFrameId = ''; @@ -1484,37 +1500,38 @@ export class Frame extends SdkObject { } } - async expect(progress: Progress, selector: string | undefined, options: FrameExpectParams): Promise { - const lastIntermediateResult: { received?: ExpectReceived, isSet: boolean, errorMessage?: string } = { isSet: false }; - const fixupMetadataError = (result: ExpectResult) => { - // Library mode special case for the expect errors which are return values, not exceptions. - if (result.matches === options.isNot) - progress.metadata.error = { error: { name: 'Expect', message: 'Expect failed' } }; - }; + async expect(progress: Progress, selector: string | undefined, options: FrameExpectParams): Promise { + if (options.expression === 'to.match.aria' && options.expectedValue) { + try { + options = { ...options, expectedValue: parseAriaSnapshotUnsafe(yaml, options.expectedValue) }; + } catch (e) { + throw new ExpectError({ customErrorMessage: e.message }); + } + } + // `isSet` distinguishes "not collected yet" from "collected with received: undefined". + const lastIntermediateResult: { isSet: boolean, received?: ExpectReceived, errorMessage?: string } = { isSet: false }; try { // Step 1: perform locator handlers checkpoint with a specified timeout. if (selector) progress.log(`waiting for ${this._asLocator(selector)}`); - if (!options.noAutoWaiting) - await this._page.performActionPreChecks(progress); + await this._page.performActionPreChecks(progress); // Step 2: perform one-shot expect check without a timeout. // Supports the case of `expect(locator).toBeVisible({ timeout: 1 })` // that should succeed when the locator is already visible. try { const resultOneShot = await this._expectInternal(progress, selector, options, lastIntermediateResult, true); - if (options.noAutoWaiting || resultOneShot.matches !== options.isNot) - return resultOneShot; + if (resultOneShot.matches !== options.isNot) + return; } catch (e) { - if (options.noAutoWaiting || this.isNonRetriableError(e)) + if (this.isNonRetriableError(e)) throw e; // Ignore any other errors from one-shot, we'll handle them during retries. } // Step 3: auto-retry expect with increasing timeouts. Bounded by the total remaining time. - const result = await this.retryWithProgressAndBackoff(progress, async (progress, continuePolling) => { - if (!options.noAutoWaiting) - await this._page.performActionPreChecks(progress); + await this.retryWithProgressAndBackoff(progress, async (progress, continuePolling) => { + await this._page.performActionPreChecks(progress); const { matches, received } = await this._expectInternal(progress, selector, options, lastIntermediateResult, false); if (matches === options.isNot) { // Keep waiting in these cases: @@ -1524,24 +1541,19 @@ export class Frame extends SdkObject { } return { matches, received }; }); - fixupMetadataError(result); - return result; } catch (e) { - // Q: Why not throw upon isNonRetriableError(e) as in other places? - // A: We want user to receive a friendly message containing the last intermediate result. - const result: ExpectResult = { matches: options.isNot, log: compressCallLog(progress.metadata.log) }; + const details: ExpectErrorDetails = {}; if (isInvalidSelectorError(e)) { - result.errorMessage = 'Error: ' + e.message; + details.customErrorMessage = e.message; } else if (js.isJavaScriptErrorInEvaluate(e)) { - result.errorMessage = e.message; + details.customErrorMessage = e.message.startsWith('Error: ') ? e.message.substring('Error: '.length) : e.message; } else if (lastIntermediateResult.isSet) { - result.received = lastIntermediateResult.received; - result.errorMessage = lastIntermediateResult.errorMessage; + details.received = lastIntermediateResult.received; + details.customErrorMessage = lastIntermediateResult.errorMessage; } if (e instanceof TimeoutError) - result.timedOut = true; - fixupMetadataError(result); - return result; + details.timedOut = true; + throw new ExpectError(details); } } @@ -1577,7 +1589,7 @@ export class Frame extends SdkObject { progressLog(log); // Note: missingReceived avoids `unexpected value "undefined"` when element was not found. if (matches === options.isNot) { - lastIntermediateResult.errorMessage = missingReceived ? 'Error: element(s) not found' : undefined; + lastIntermediateResult.errorMessage = missingReceived ? 'element(s) not found' : undefined; lastIntermediateResult.received = received; lastIntermediateResult.isSet = true; if (!missingReceived && !Array.isArray(received?.value)) diff --git a/packages/playwright-core/src/server/page.ts b/packages/playwright-core/src/server/page.ts index dbe1dbba12eee..c26d5d39ddb1a 100644 --- a/packages/playwright-core/src/server/page.ts +++ b/packages/playwright-core/src/server/page.ts @@ -705,7 +705,7 @@ export class Page extends SdkObject { await this.delegate.updateRequestInterception(); } - async expectScreenshot(progress: Progress, options: ExpectScreenshotOptions): Promise<{ actual?: Buffer, previous?: Buffer, diff?: Buffer, errorMessage?: string, log?: string[], timedOut?: boolean }> { + async expectScreenshot(progress: Progress, options: ExpectScreenshotOptions): Promise<{ actual?: Buffer }> { const locator = options.locator; const rafrafScreenshot = locator ? async (progress: Progress, timeout: number) => { return await locator.frame.rafrafTimeoutScreenshotElementWithProgress(progress, locator.selector, timeout, options || {}); @@ -716,15 +716,6 @@ export class Page extends SdkObject { }; const comparator = getComparator('image/png'); - if (!options.expected && options.isNot) - return { errorMessage: '"not" matcher requires expected result' }; - try { - const format = validateScreenshotOptions(options || {}); - if (format !== 'png') - throw new Error('Only PNG screenshots are supported'); - } catch (error) { - return { errorMessage: error.message }; - } let intermediateResult: { actual?: Buffer, previous?: Buffer, @@ -741,6 +732,11 @@ export class Page extends SdkObject { }; try { + if (!options.expected && options.isNot) + throw new Error('"not" matcher requires expected result'); + const format = validateScreenshotOptions(options || {}); + if (format !== 'png') + throw new Error('Only PNG screenshots are supported'); let actual: Buffer | undefined; let previous: Buffer | undefined; const pollIntervals = [0, 100, 250, 500]; @@ -797,12 +793,17 @@ export class Page extends SdkObject { let errorMessage = e.message; if (e instanceof TimeoutError && intermediateResult?.previous) errorMessage = `Failed to take two consecutive stable screenshots.`; - return { + const details: channels.PageExpectScreenshotErrorDetails = { log: compressCallLog(e.message ? [...progress.metadata.log, e.message] : progress.metadata.log), - ...intermediateResult, - errorMessage, + actual: intermediateResult?.actual, + previous: intermediateResult?.previous, + diff: intermediateResult?.diff, timedOut: (e instanceof TimeoutError), + customErrorMessage: errorMessage, }; + const error = new Error('Expect failed'); + (error as any).details = details; + throw error; } } diff --git a/packages/playwright-core/src/server/recorder.ts b/packages/playwright-core/src/server/recorder.ts index 43ad5d0df1458..925520399bc17 100644 --- a/packages/playwright-core/src/server/recorder.ts +++ b/packages/playwright-core/src/server/recorder.ts @@ -582,9 +582,12 @@ export class Recorder extends EventEmitter implements Instrume private async _performAction(progress: Progress, frame: Frame, action: actions.PerformOnRecordAction) { const actionInContext = await this._createActionInContext(progress, frame, action); this._signalProcessor.addAction(actionInContext); - if (actionInContext.action.name !== 'openPage' && actionInContext.action.name !== 'closePage') - await performAction(progress, this._pageAliases, actionInContext); - actionInContext.endTime = monotonicTime(); + try { + if (actionInContext.action.name !== 'openPage' && actionInContext.action.name !== 'closePage') + await performAction(progress, this._pageAliases, actionInContext); + } finally { + actionInContext.endTime = monotonicTime(); + } } private async _recordAction(progress: Progress, frame: Frame, action: actions.Action) { diff --git a/packages/protocol/spec/frame.yml b/packages/protocol/spec/frame.yml index 4c92922cdfdc8..a2ca0bfd7103b 100644 --- a/packages/protocol/spec/frame.yml +++ b/packages/protocol/spec/frame.yml @@ -766,18 +766,14 @@ Frame: useInnerText: boolean? isNot: boolean timeout: float - returns: - matches: boolean + errorDetails: received: type: object? properties: value: SerializedValue? ariaSnapshot: string? timedOut: boolean? - errorMessage: string? - log: - type: array? - items: string + customErrorMessage: string? flags: snapshot: true pause: true diff --git a/packages/protocol/spec/page.yml b/packages/protocol/spec/page.yml index c1b077c04bf07..73a3adc21810b 100644 --- a/packages/protocol/spec/page.yml +++ b/packages/protocol/spec/page.yml @@ -189,8 +189,10 @@ Page: clip: Rect? $mixin: CommonScreenshotOptions returns: + actual: binary? + errorDetails: diff: binary? - errorMessage: string? + customErrorMessage: string? actual: binary? previous: binary? timedOut: boolean? diff --git a/packages/protocol/src/channels.d.ts b/packages/protocol/src/channels.d.ts index 02409015ca5a4..09fb8655f9285 100644 --- a/packages/protocol/src/channels.d.ts +++ b/packages/protocol/src/channels.d.ts @@ -3030,15 +3030,14 @@ export type FrameExpectOptions = { expectedValue?: SerializedArgument, useInnerText?: boolean, }; -export type FrameExpectResult = { - matches: boolean, +export type FrameExpectResult = void; +export type FrameExpectErrorDetails = { received?: { value?: SerializedValue, ariaSnapshot?: string, }, timedOut?: boolean, - errorMessage?: string, - log?: string[], + customErrorMessage?: string, }; export interface FrameEvents { @@ -4325,8 +4324,11 @@ export type PageExpectScreenshotOptions = { style?: string, }; export type PageExpectScreenshotResult = { + actual?: Binary, +}; +export type PageExpectScreenshotErrorDetails = { diff?: Binary, - errorMessage?: string, + customErrorMessage?: string, actual?: Binary, previous?: Binary, timedOut?: boolean, diff --git a/tests/library/inspector/pause.spec.ts b/tests/library/inspector/pause.spec.ts index a7c820343dbb6..74c5dfd61e43a 100644 --- a/tests/library/inspector/pause.spec.ts +++ b/tests/library/inspector/pause.spec.ts @@ -395,6 +395,27 @@ it.describe('pause', () => { expect(error.message).toContain('Not a checkbox or radio button'); }); + it('should populate log with expect failure', async ({ page, recorderPageGetter }) => { + await page.setContent(''); + const scriptPromise = (async () => { + // @ts-ignore + await page.pause({ __testHookKeepTestTimeout: true }); + await expect(page.getByRole('button')).toHaveText('Other', { timeout: 1 }); + })().catch(e => e); + const recorderPage = await recorderPageGetter(); + await recorderPage.click('[title="Resume (F8)"]'); + await recorderPage.waitForSelector('.source-line-error-underline'); + expect(await sanitizeLog(recorderPage)).toEqual([ + 'Pause- XXms', + 'Expect "toHaveText"(page.getByRole(\'button\'))- XXms', + 'Expect "toHaveText" with timeout 1ms', + 'waiting for getByRole(\'button\')', + 'error: Expect failed', + ]); + const error = await scriptPromise; + expect(error.message).toContain('toHaveText'); + }); + it('should populate log with error in waitForEvent', async ({ page, recorderPageGetter }) => { await page.setContent(''); const scriptPromise = (async () => { diff --git a/tests/library/trace-viewer.spec.ts b/tests/library/trace-viewer.spec.ts index b5a2b3903b876..1ea2337efc018 100644 --- a/tests/library/trace-viewer.spec.ts +++ b/tests/library/trace-viewer.spec.ts @@ -403,8 +403,6 @@ test('should show params and return value', async ({ showTraceViewer }) => { /locator:locator\('button'\)/, /expression:"to.have.text"/, /timeout:10000/, - /matches:true/, - /received:"Click"/, ]); }); diff --git a/tests/page/to-match-aria-snapshot.spec.ts b/tests/page/to-match-aria-snapshot.spec.ts index fe951e052b7f7..14e4fb1bb1bba 100644 --- a/tests/page/to-match-aria-snapshot.spec.ts +++ b/tests/page/to-match-aria-snapshot.spec.ts @@ -609,17 +609,30 @@ test('should report error in YAML', async ({ page }) => { const error = await expect(page).toMatchAriaSnapshot(` heading "title" `).catch(e => e); - expect.soft(error.message).toBe(`expect.toMatchAriaSnapshot: Aria snapshot must be a YAML sequence, elements starting with " -"`); + expect.soft(stripAnsi(error.message)).toBe(`expect(page).toMatchAriaSnapshot(expected) failed + +Expected: "heading \\"title\\"" +Error: Aria snapshot must be a YAML sequence, elements starting with " -" + +Call log: + - Expect "toMatchAriaSnapshot" with timeout 10000ms +`); } { const error = await expect(page).toMatchAriaSnapshot(` - heading: a: `).catch(e => e); - expect.soft(error.message).toBe(`expect.toMatchAriaSnapshot: Nested mappings are not allowed in compact mappings at line 1, column 12: + expect.soft(stripAnsi(error.message)).toBe(`expect(page).toMatchAriaSnapshot(expected) failed + +Expected: "- heading: a:" +Error: Nested mappings are not allowed in compact mappings at line 1, column 12: - heading: a: ^ + +Call log: + - Expect "toMatchAriaSnapshot" with timeout 10000ms `); } }); @@ -631,10 +644,16 @@ test('should report error in YAML keys', async ({ page }) => { const error = await expect(page).toMatchAriaSnapshot(` - heading "title `).catch(e => e); - expect.soft(error.message).toBe(`expect.toMatchAriaSnapshot: Unterminated string: + expect.soft(stripAnsi(error.message)).toBe(`expect(page).toMatchAriaSnapshot(expected) failed + +Expected: "- heading \\"title" +Error: Unterminated string: heading "title ^ + +Call log: + - Expect "toMatchAriaSnapshot" with timeout 10000ms `); } @@ -642,10 +661,16 @@ heading "title const error = await expect(page).toMatchAriaSnapshot(` - heading /title `).catch(e => e); - expect.soft(error.message).toBe(`expect.toMatchAriaSnapshot: Unterminated regex: + expect.soft(stripAnsi(error.message)).toBe(`expect(page).toMatchAriaSnapshot(expected) failed + +Expected: "- heading /title" +Error: Unterminated regex: heading /title ^ + +Call log: + - Expect "toMatchAriaSnapshot" with timeout 10000ms `); } @@ -653,10 +678,16 @@ heading /title const error = await expect(page).toMatchAriaSnapshot(` - heading [level=a] `).catch(e => e); - expect.soft(error.message).toBe(`expect.toMatchAriaSnapshot: Value of "level" attribute must be a number: + expect.soft(stripAnsi(error.message)).toBe(`expect(page).toMatchAriaSnapshot(expected) failed + +Expected: "- heading [level=a]" +Error: Value of "level" attribute must be a number: heading [level=a] ^ + +Call log: + - Expect "toMatchAriaSnapshot" with timeout 10000ms `); } @@ -664,10 +695,16 @@ heading [level=a] const error = await expect(page).toMatchAriaSnapshot(` - heading [expanded=FALSE] `).catch(e => e); - expect.soft(error.message).toBe(`expect.toMatchAriaSnapshot: Value of "expanded" attribute must be a boolean: + expect.soft(stripAnsi(error.message)).toBe(`expect(page).toMatchAriaSnapshot(expected) failed + +Expected: "- heading [expanded=FALSE]" +Error: Value of "expanded" attribute must be a boolean: heading [expanded=FALSE] ^ + +Call log: + - Expect "toMatchAriaSnapshot" with timeout 10000ms `); } @@ -675,10 +712,16 @@ heading [expanded=FALSE] const error = await expect(page).toMatchAriaSnapshot(` - heading [checked=foo] `).catch(e => e); - expect.soft(error.message).toBe(`expect.toMatchAriaSnapshot: Value of "checked" attribute must be a boolean or "mixed": + expect.soft(stripAnsi(error.message)).toBe(`expect(page).toMatchAriaSnapshot(expected) failed + +Expected: "- heading [checked=foo]" +Error: Value of "checked" attribute must be a boolean or "mixed": heading [checked=foo] ^ + +Call log: + - Expect "toMatchAriaSnapshot" with timeout 10000ms `); } @@ -686,10 +729,16 @@ heading [checked=foo] const error = await expect(page).toMatchAriaSnapshot(` - heading [level=] `).catch(e => e); - expect.soft(error.message).toBe(`expect.toMatchAriaSnapshot: Value of "level" attribute must be a number: + expect.soft(stripAnsi(error.message)).toBe(`expect(page).toMatchAriaSnapshot(expected) failed + +Expected: "- heading [level=]" +Error: Value of "level" attribute must be a number: heading [level=] ^ + +Call log: + - Expect "toMatchAriaSnapshot" with timeout 10000ms `); } @@ -697,10 +746,16 @@ heading [level=] const error = await expect(page).toMatchAriaSnapshot(` - heading [bogus] `).catch(e => e); - expect.soft(error.message).toBe(`expect.toMatchAriaSnapshot: Unsupported attribute [bogus]: + expect.soft(stripAnsi(error.message)).toBe(`expect(page).toMatchAriaSnapshot(expected) failed + +Expected: "- heading [bogus]" +Error: Unsupported attribute [bogus]: heading [bogus] ^ + +Call log: + - Expect "toMatchAriaSnapshot" with timeout 10000ms `); } @@ -708,10 +763,16 @@ heading [bogus] const error = await expect(page).toMatchAriaSnapshot(` - heading invalid `).catch(e => e); - expect.soft(error.message).toBe(`expect.toMatchAriaSnapshot: Unexpected input: + expect.soft(stripAnsi(error.message)).toBe(`expect(page).toMatchAriaSnapshot(expected) failed + +Expected: "- heading invalid" +Error: Unexpected input: heading invalid ^ + +Call log: + - Expect "toMatchAriaSnapshot" with timeout 10000ms `); } }); diff --git a/utils/generate_channels.js b/utils/generate_channels.js index cd33d24177579..33856d271fb3e 100755 --- a/utils/generate_channels.js +++ b/utils/generate_channels.js @@ -341,6 +341,15 @@ for (const [name, item] of Object.entries(protocol)) { for (const derived of derivedClasses.get(channelName) || []) addScheme(`${derived}${titleCase(methodName)}Result`, `tType('${resultName}')`); + if (method.errorDetails) { + const errorDetailsName = `${channelName}${titleCase(methodName)}ErrorDetails`; + const details = objectType(method.errorDetails, ''); + ts_types.set(errorDetailsName, details.ts); + addScheme(errorDetailsName, details.scheme); + for (const derived of derivedClasses.get(channelName) || []) + addScheme(`${derived}${titleCase(methodName)}ErrorDetails`, `tType('${errorDetailsName}')`); + } + channels_ts.push(` ${methodName}(params${method.parameters ? '' : '?'}: ${paramsName}, progress?: Progress): Promise<${resultName}>;`); } From c30ccc68f833378087338ed9168175e1ce942c00 Mon Sep 17 00:00:00 2001 From: pikachu Date: Fri, 5 Jun 2026 20:38:46 +0530 Subject: [PATCH 3/5] fix(chromium): propagate `setUserAgent()` to service workers via `Network` domain (#41071) --- .../src/server/chromium/crBrowser.ts | 11 ++++++----- .../src/server/chromium/crPage.ts | 2 +- .../src/server/chromium/crServiceWorker.ts | 11 +++++++++++ tests/library/chromium/extensions.spec.ts | 17 +++++++++++++++++ 4 files changed, 35 insertions(+), 6 deletions(-) diff --git a/packages/playwright-core/src/server/chromium/crBrowser.ts b/packages/playwright-core/src/server/chromium/crBrowser.ts index ec96fb94966da..27a3409fb5c28 100644 --- a/packages/playwright-core/src/server/chromium/crBrowser.ts +++ b/packages/playwright-core/src/server/chromium/crBrowser.ts @@ -30,7 +30,7 @@ import { CRPage } from './crPage'; import { saveProtocolStream } from './crProtocolHelper'; import { CRServiceWorker } from './crServiceWorker'; -import type { InitScript, Worker } from '../page'; +import type { InitScript } from '../page'; import type { ConnectionTransport } from '../transport'; import type * as types from '../types'; import type { CDPSession, CRSession } from './crConnection'; @@ -496,9 +496,10 @@ export class CRBrowserContext extends BrowserContext { async setUserAgent(userAgent: string | undefined): Promise { this._options.userAgent = userAgent; - for (const page of this.pages()) - await (page.delegate as CRPage).updateUserAgent(); - // TODO: service workers don't have Emulation domain? + await Promise.all([ + ...this.pages().map(page => (page.delegate as CRPage).updateUserAgent()), + ...this.serviceWorkers().map(sw => sw.updateUserAgent()), + ]); } async doUpdateOffline(): Promise { @@ -593,7 +594,7 @@ export class CRBrowserContext extends BrowserContext { }); } - serviceWorkers(): Worker[] { + serviceWorkers(): CRServiceWorker[] { return Array.from(this._browser._serviceWorkers.values()).filter(serviceWorker => serviceWorker.browserContext === this); } diff --git a/packages/playwright-core/src/server/chromium/crPage.ts b/packages/playwright-core/src/server/chromium/crPage.ts index 19556b0536e8e..cbb1d4eb13b8d 100644 --- a/packages/playwright-core/src/server/chromium/crPage.ts +++ b/packages/playwright-core/src/server/chromium/crPage.ts @@ -1165,7 +1165,7 @@ async function emulateTimezone(session: CRSession, timezoneId: string) { } // Chromium reference: https://source.chromium.org/chromium/chromium/src/+/main:components/embedder_support/user_agent_utils.cc;l=434;drc=70a6711e08e9f9e0d8e4c48e9ba5cab62eb010c2 -function calculateUserAgentMetadata(options: types.BrowserContextOptions) { +export function calculateUserAgentMetadata(options: types.BrowserContextOptions) { const ua = options.userAgent; if (!ua) return undefined; diff --git a/packages/playwright-core/src/server/chromium/crServiceWorker.ts b/packages/playwright-core/src/server/chromium/crServiceWorker.ts index da83759bcae54..abd6f58ad75a8 100644 --- a/packages/playwright-core/src/server/chromium/crServiceWorker.ts +++ b/packages/playwright-core/src/server/chromium/crServiceWorker.ts @@ -20,6 +20,7 @@ import { BrowserContext } from '../browserContext'; import * as network from '../network'; import { ConsoleMessage } from '../console'; import { stackTraceToLocation } from './crProtocolHelper'; +import { calculateUserAgentMetadata } from './crPage'; import type { CRBrowserContext } from './crBrowser'; import type { CRSession } from './crConnection'; @@ -53,6 +54,7 @@ export class CRServiceWorker extends Worker { this.updateExtraHTTPHeaders(); this.updateHttpCredentials(); this.updateOffline(); + this.updateUserAgent(); this._networkManager.addSession(session, undefined, true /* isMain */).catch(() => {}); } @@ -96,6 +98,15 @@ export class CRServiceWorker extends Worker { await this._networkManager?.setExtraHTTPHeaders(this.browserContext._options.extraHTTPHeaders || []).catch(() => {}); } + async updateUserAgent(): Promise { + const options = this.browserContext._options; + await this._session.send('Emulation.setUserAgentOverride', { + userAgent: options.userAgent || '', + acceptLanguage: options.locale, + userAgentMetadata: calculateUserAgentMetadata(options), + }).catch(() => {}); + } + async updateRequestInterception(): Promise { if (!this._isNetworkInspectionEnabled()) return; diff --git a/tests/library/chromium/extensions.spec.ts b/tests/library/chromium/extensions.spec.ts index c27fd1cdfe1bb..3194d5554c5db 100644 --- a/tests/library/chromium/extensions.spec.ts +++ b/tests/library/chromium/extensions.spec.ts @@ -164,4 +164,21 @@ it.describe('MV3', () => { expect(message.text()).toContain('Test console log from a third-party execution context'); await context.close(); }); + + it('should use custom userAgent in service worker fetch requests', async ({ launchPersistentContext, asset, server }) => { + server.setRoute('/ua-echo', (req, res) => { + res.writeHead(200, { 'Content-Type': 'text/plain' }); + res.end(req.headers['user-agent']); + }); + const extensionPath = asset('extension-mv3-simple'); + const context = await launchPersistentContext(extensionPath, { userAgent: 'MyTestAgent/1.0' }); + const serviceWorkers = context.serviceWorkers(); + const sw = serviceWorkers.length ? serviceWorkers[0] : await context.waitForEvent('serviceworker'); + const userAgent = await sw.evaluate(async url => { + const response = await fetch(url); + return response.text(); + }, server.PREFIX + '/ua-echo'); + expect(userAgent).toBe('MyTestAgent/1.0'); + await context.close(); + }); }); From 749d0ef6983c44142223ca2b4358115869f677bd Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Fri, 5 Jun 2026 18:25:17 +0100 Subject: [PATCH 4/5] chore: do not capture aria snapshot for passing expects (#41163) --- packages/injected/src/injectedScript.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/injected/src/injectedScript.ts b/packages/injected/src/injectedScript.ts index c07b844f97b64..f91fdbed3bde2 100644 --- a/packages/injected/src/injectedScript.ts +++ b/packages/injected/src/injectedScript.ts @@ -1446,7 +1446,7 @@ export class InjectedScript { async expect(element: Element | undefined, options: FrameExpectParams, elements: Element[]): Promise<{ matches: boolean, received?: ExpectReceived, missingReceived?: boolean }> { const core = await this._expectCore(element, options, elements); - const ariaSnapshot = this._ariaSnapshotForExpect(element, options); + const ariaSnapshot = core.matches !== options.isNot ? undefined : this._ariaSnapshotForExpect(element, options); if (core.received === undefined && ariaSnapshot === undefined) return { matches: core.matches, missingReceived: core.missingReceived }; return { matches: core.matches, received: { value: core.received, ariaSnapshot }, missingReceived: core.missingReceived }; From ae106c05e5a40486ab5b9704234c32f0499e9719 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Fri, 5 Jun 2026 10:27:07 -0700 Subject: [PATCH 5/5] Revert "feat(test): support per-project webServer configuration (#40869)" (#41167) --- docs/src/api/params.md | 22 -- docs/src/test-api/class-testconfig.md | 20 +- docs/src/test-api/class-testproject.md | 42 ---- packages/playwright/src/common/config.ts | 6 +- .../playwright/src/common/configLoader.ts | 13 -- packages/playwright/src/plugins/index.ts | 3 - .../playwright/src/plugins/webServerPlugin.ts | 42 ++-- packages/playwright/src/runner/tasks.ts | 14 +- packages/playwright/src/runner/testRunner.ts | 4 +- packages/playwright/types/test.d.ts | 48 ----- tests/playwright-test/web-server.spec.ts | 189 +----------------- utils/generate_types/overrides-test.d.ts | 1 - 12 files changed, 43 insertions(+), 361 deletions(-) diff --git a/docs/src/api/params.md b/docs/src/api/params.md index c59cb1e920f5e..0e885a7aa363b 100644 --- a/docs/src/api/params.md +++ b/docs/src/api/params.md @@ -1973,27 +1973,6 @@ In this config: 1. Since `snapshotPathTemplate` resolves to relative path, it will be resolved relative to `configDir`. 1. Forward slashes `"/"` can be used as path separators on any platform. -## test-config-web-server-options -* langs: js -- type: ?<[Object]|[Array]<[Object]>> - - `command` <[string]> Shell command to start. For example `npm run start`.. - - `cwd` ?<[string]> Current working directory of the spawned process, defaults to the directory of the configuration file. - - `env` ?<[Object]<[string], [string]>> Environment variables to set for the command, `process.env` by default. - - `gracefulShutdown` ?<[Object]> How to shut down the process. If unspecified, the process group is forcefully `SIGKILL`ed. If set to `{ signal: 'SIGTERM', timeout: 500 }`, the process group is sent a `SIGTERM` signal, followed by `SIGKILL` if it doesn't exit within 500ms. You can also use `SIGINT` as the signal instead. A `0` timeout means no `SIGKILL` will be sent. Windows doesn't support `SIGTERM` and `SIGINT` signals, so this option is ignored on Windows. Note that shutting down a Docker container requires `SIGTERM`. - - `signal` <["SIGINT"|"SIGTERM"]> - - `timeout` <[int]> - - `ignoreHTTPSErrors` ?<[boolean]> Whether to ignore HTTPS errors when fetching the `url`. Defaults to `false`. - - `name` ?<[string]> Specifies a custom name for the web server. This name will be prefixed to log messages. Defaults to `[WebServer]`. - - `port` ?<[int]> The port that your http server is expected to appear on. It does wait until it accepts connections. Either `port` or `url` should be specified. - - `reuseExistingServer` ?<[boolean]> If true, it will re-use an existing server on the `port` or `url` when available. If no server is running on that `port` or `url`, it will run the command to start a new server. If `false`, it will throw if an existing process is listening on the `port` or `url`. This should be commonly set to `!process.env.CI` to allow the local dev server when running tests locally. - - `stderr` ?<["pipe"|"ignore"]> Whether to pipe the stderr of the command to the process stderr or ignore it. Defaults to `"pipe"`. - - `stdout` ?<["pipe"|"ignore"]> If `"pipe"`, it will pipe the stdout of the command to the process stdout. If `"ignore"`, it will ignore the stdout of the command. Default to `"ignore"`. - - `wait` ?<[Object]> Consider command started only when given output has been produced. - - `stdout` ?<[RegExp]> Regular expression to wait for in the `stdout` of the command output. Named capture groups are stored in the environment, for example `/Listening on port (?\d+)/` will store the port number in `process.env['MY_SERVER_PORT']`. - - `stderr` ?<[RegExp]> Regular expression to wait for in the `stderr` of the command output. Named capture groups are stored in the environment, for example `/Listening on port (?\d+)/` will store the port number in `process.env['MY_SERVER_PORT']`. - - `timeout` ?<[int]> How long to wait for the process to start up and be available in milliseconds. Defaults to 60000. - - `url` ?<[string]> The url on your http server that is expected to return a 2xx, 3xx, 400, 401, 402, or 403 status code when the server is ready to accept connections. Redirects (3xx status codes) are being followed and the new location is checked. Either `port` or `url` should be specified. - ## response-security-details - returns: <[null]|[Object]> * alias: SecurityDetails @@ -2014,4 +1993,3 @@ In this config: * alias-java: ServerAddr - `ipAddress` <[string]> IPv4 or IPV6 address of the server. - `port` <[int]> - diff --git a/docs/src/test-api/class-testconfig.md b/docs/src/test-api/class-testconfig.md index 83cae0624a263..88b059a35a145 100644 --- a/docs/src/test-api/class-testconfig.md +++ b/docs/src/test-api/class-testconfig.md @@ -690,8 +690,26 @@ export default defineConfig({ }); ``` -## property: TestConfig.webServer = %%-test-config-web-server-options-%% +## property: TestConfig.webServer * since: v1.10 +- type: ?<[Object]|[Array]<[Object]>> + - `command` <[string]> Shell command to start. For example `npm run start`.. + - `cwd` ?<[string]> Current working directory of the spawned process, defaults to the directory of the configuration file. + - `env` ?<[Object]<[string], [string]>> Environment variables to set for the command, `process.env` by default. + - `gracefulShutdown` ?<[Object]> How to shut down the process. If unspecified, the process group is forcefully `SIGKILL`ed. If set to `{ signal: 'SIGTERM', timeout: 500 }`, the process group is sent a `SIGTERM` signal, followed by `SIGKILL` if it doesn't exit within 500ms. You can also use `SIGINT` as the signal instead. A `0` timeout means no `SIGKILL` will be sent. Windows doesn't support `SIGTERM` and `SIGINT` signals, so this option is ignored on Windows. Note that shutting down a Docker container requires `SIGTERM`. + - `signal` <["SIGINT"|"SIGTERM"]> + - `timeout` <[int]> + - `ignoreHTTPSErrors` ?<[boolean]> Whether to ignore HTTPS errors when fetching the `url`. Defaults to `false`. + - `name` ?<[string]> Specifies a custom name for the web server. This name will be prefixed to log messages. Defaults to `[WebServer]`. + - `port` ?<[int]> The port that your http server is expected to appear on. It does wait until it accepts connections. Either `port` or `url` should be specified. + - `reuseExistingServer` ?<[boolean]> If true, it will re-use an existing server on the `port` or `url` when available. If no server is running on that `port` or `url`, it will run the command to start a new server. If `false`, it will throw if an existing process is listening on the `port` or `url`. This should be commonly set to `!process.env.CI` to allow the local dev server when running tests locally. + - `stderr` ?<["pipe"|"ignore"]> Whether to pipe the stderr of the command to the process stderr or ignore it. Defaults to `"pipe"`. + - `stdout` ?<["pipe"|"ignore"]> If `"pipe"`, it will pipe the stdout of the command to the process stdout. If `"ignore"`, it will ignore the stdout of the command. Default to `"ignore"`. + - `wait` ?<[Object]> Consider command started only when given output has been produced. + - `stdout` ?<[RegExp]> Regular expression to wait for in the `stdout` of the command output. Named capture groups are stored in the environment, for example `/Listening on port (?\d+)/` will store the port number in `process.env['MY_SERVER_PORT']`. + - `stderr` ?<[RegExp]> Regular expression to wait for in the `stderr` of the command output. Named capture groups are stored in the environment, for example `/Listening on port (?\d+)/` will store the port number in `process.env['MY_SERVER_PORT']`. + - `timeout` ?<[int]> How long to wait for the process to start up and be available in milliseconds. Defaults to 60000. + - `url` ?<[string]> The url on your http server that is expected to return a 2xx, 3xx, 400, 401, 402, or 403 status code when the server is ready to accept connections. Redirects (3xx status codes) are being followed and the new location is checked. Either `port` or `url` should be specified. Launch a development web server (or multiple) during the tests. diff --git a/docs/src/test-api/class-testproject.md b/docs/src/test-api/class-testproject.md index 6cb2a98e147c7..ce55beb069a40 100644 --- a/docs/src/test-api/class-testproject.md +++ b/docs/src/test-api/class-testproject.md @@ -395,48 +395,6 @@ export default defineConfig({ Use [`property: TestConfig.use`] to change this option for all projects. -## property: TestProject.webServer = %%-test-config-web-server-options-%% -* since: v1.61 - -Launch a development web server (or multiple) before running tests in this project. See [`property: TestConfig.webServer`] for the shape of each entry. - -A per-project `webServer` is only launched when the project is selected (either directly via `--project` or indirectly through dependencies). This is useful when only a subset of your projects need a local backend, while others run against a deployed environment. - -Per-project web servers are launched in addition to any top-level [`property: TestConfig.webServer`]. - -**Usage** - -```js title="playwright.config.ts" -import { defineConfig } from '@playwright/test'; - -export default defineConfig({ - projects: [ - { - name: 'functional', - grepInvert: /@smoke/, - use: { baseURL: 'http://localhost:3000' }, - webServer: [ - { - command: 'npm run start', - url: 'http://localhost:3000', - reuseExistingServer: !process.env.CI, - }, - { - command: 'npm run mock-server', - port: 3001, - reuseExistingServer: !process.env.CI, - }, - ], - }, - { - name: 'smoke', - grep: /@smoke/, - use: { baseURL: 'https://production.app.com' }, - }, - ], -}); -``` - ## property: TestProject.workers * since: v1.52 - type: ?<[int]|[string]> diff --git a/packages/playwright/src/common/config.ts b/packages/playwright/src/common/config.ts index 2b8ae1a60f75b..e521d61c49802 100644 --- a/packages/playwright/src/common/config.ts +++ b/packages/playwright/src/common/config.ts @@ -128,8 +128,7 @@ export class FullConfigInternal { } // When no projects are defined, do not use config.workers as a hard limit for project.workers. - // Strip webServer from the implicit default project — it is already accounted for at the top level. - const projectConfigs = configCLIOverrides.projects || userConfig.projects || [{ ...userConfig, workers: undefined, webServer: undefined }]; + const projectConfigs = configCLIOverrides.projects || userConfig.projects || [{ ...userConfig, workers: undefined }]; this.projects = projectConfigs.map(p => new FullProjectInternal(configDir, userConfig, this, p, this.configCLIOverrides, packageJsonDir)); resolveProjectDependencies(this.projects); this._assignUniqueProjectIds(this.projects); @@ -161,7 +160,6 @@ export class FullProjectInternal { readonly respectGitIgnore: boolean; readonly snapshotPathTemplate: string | undefined; readonly workers: number | undefined; - readonly webServers: NonNullable[]; id = ''; deps: FullProjectInternal[] = []; teardown: FullProjectInternal | undefined; @@ -170,8 +168,6 @@ export class FullProjectInternal { this.fullConfig = fullConfig; const testDir = takeFirst(pathResolve(configDir, projectConfig.testDir), pathResolve(configDir, config.testDir), fullConfig.configDir); this.snapshotPathTemplate = takeFirst(projectConfig.snapshotPathTemplate, config.snapshotPathTemplate); - const webServer = projectConfig.webServer; - this.webServers = Array.isArray(webServer) ? webServer : webServer ? [webServer] : []; this.project = { grep: takeFirst(projectConfig.grep, config.grep, defaultGrep), diff --git a/packages/playwright/src/common/configLoader.ts b/packages/playwright/src/common/configLoader.ts index 0af93470ee5aa..32f3a5621724f 100644 --- a/packages/playwright/src/common/configLoader.ts +++ b/packages/playwright/src/common/configLoader.ts @@ -315,19 +315,6 @@ function validateProject(file: string, project: Project, title: string) { else if (typeof project.workers === 'string' && !project.workers.endsWith('%')) throw errorWithFile(file, `${title}.workers must be a number or percentage`); } - - if ('webServer' in project && project.webServer !== undefined) { - const webServer = project.webServer; - const isArray = Array.isArray(webServer); - const items = isArray ? webServer : [webServer]; - items.forEach((item, index) => { - const itemTitle = isArray ? `${title}.webServer[${index}]` : `${title}.webServer`; - if (!item || typeof item !== 'object') - throw errorWithFile(file, `${itemTitle} must be an object`); - if (item.command !== undefined && (typeof item.command !== 'string' || !item.command)) - throw errorWithFile(file, `${itemTitle}.command must be a non-empty string`); - }); - } } export function resolveConfigLocation(configFile: string | undefined): ConfigLocation { diff --git a/packages/playwright/src/plugins/index.ts b/packages/playwright/src/plugins/index.ts index 509874f2e4934..4f2e878a4be6a 100644 --- a/packages/playwright/src/plugins/index.ts +++ b/packages/playwright/src/plugins/index.ts @@ -30,9 +30,6 @@ export interface TestRunnerPlugin { export type TestRunnerPluginRegistration = { factory: TestRunnerPlugin | (() => TestRunnerPlugin | Promise); instance?: TestRunnerPlugin; - // When set, the plugin is only set up when the project (or their - // transitive closure of dependencies/teardowns) is selected to run. - projectId?: string; }; export { webServer } from './webServerPlugin'; diff --git a/packages/playwright/src/plugins/webServerPlugin.ts b/packages/playwright/src/plugins/webServerPlugin.ts index a08d20bda2c13..60f0f1052a2bb 100644 --- a/packages/playwright/src/plugins/webServerPlugin.ts +++ b/packages/playwright/src/plugins/webServerPlugin.ts @@ -25,7 +25,7 @@ import { raceAgainstDeadline } from '@isomorphic/timeoutRunner'; import { isURLAvailable } from '@utils/network'; import { launchProcess } from '@utils/processLauncher'; -import type { TestRunnerPlugin, TestRunnerPluginRegistration } from '.'; +import type { TestRunnerPlugin } from '.'; import type { FullConfig } from '../../types/testReporter'; import type { FullConfigInternal } from '../common'; import type { ReporterV2 } from '../reporters/reporterV2'; @@ -259,35 +259,27 @@ export const webServer = (options: WebServerPluginOptions): TestRunnerPlugin => return new WebServerPlugin(options, false); }; -export const webServerPluginsForConfig = (config: FullConfigInternal): TestRunnerPluginRegistration[] => { +export const webServerPluginsForConfig = (config: FullConfigInternal): TestRunnerPlugin[] => { const shouldSetBaseUrl = !!config.config.webServer; - const plugins: TestRunnerPluginRegistration[] = []; - for (const webServerConfig of config.webServers) - plugins.push({ factory: createWebServerPlugin(webServerConfig, shouldSetBaseUrl) }); - - for (const project of config.projects) { - for (const webServerConfig of project.webServers) - plugins.push({ factory: createWebServerPlugin(webServerConfig, false), projectId: project.id }); + const webServerPlugins = []; + for (const webServerConfig of config.webServers) { + if (webServerConfig.port && webServerConfig.url) + throw new Error(`Either 'port' or 'url' should be specified in config.webServer.`); + + let url: string | undefined; + if (webServerConfig.port || webServerConfig.url) { + url = webServerConfig.url || `http://localhost:${webServerConfig.port}`; + + // We only set base url when only the port is given. That's a legacy mode we have regrets about. + if (shouldSetBaseUrl && !webServerConfig.url) + process.env.PLAYWRIGHT_TEST_BASE_URL = url; + } + webServerPlugins.push(new WebServerPlugin({ ...webServerConfig, url }, webServerConfig.port !== undefined)); } - return plugins; + return webServerPlugins; }; -function createWebServerPlugin(webServerConfig: WebServerPluginOptions & { port?: number }, shouldSetBaseUrl: boolean): TestRunnerPlugin { - if (webServerConfig.port && webServerConfig.url) - throw new Error(`Either 'port' or 'url' should be specified in config.webServer.`); - - let url: string | undefined; - if (webServerConfig.port || webServerConfig.url) { - url = webServerConfig.url || `http://localhost:${webServerConfig.port}`; - - // We only set base url when only the port is given. That's a legacy mode we have regrets about. - if (shouldSetBaseUrl && !webServerConfig.url) - process.env.PLAYWRIGHT_TEST_BASE_URL = url; - } - return new WebServerPlugin({ ...webServerConfig, url }, webServerConfig.port !== undefined); -} - function prefixOutputLines(output: string, prefixName: string = 'WebServer'): string { const lastIsNewLine = output[output.length - 1] === '\n'; let lines = output.split('\n'); diff --git a/packages/playwright/src/runner/tasks.ts b/packages/playwright/src/runner/tasks.ts index 50b6951177ffd..1c53cb908c981 100644 --- a/packages/playwright/src/runner/tasks.ts +++ b/packages/playwright/src/runner/tasks.ts @@ -25,7 +25,7 @@ import { removeFolders } from '@utils/fileUtils'; import { Dispatcher } from './dispatcher'; import { collectProjectsAndTestFiles, createRootSuite, loadFileSuites, loadGlobalHook, loadTestList } from './loadUtils'; -import { buildDependentProjects, buildProjectsClosure, buildTeardownToSetupsMap, filterProjects } from './projectUtils'; +import { buildDependentProjects, buildTeardownToSetupsMap, filterProjects } from './projectUtils'; import { applySuggestedRebaselines, clearSuggestedRebaselines } from './rebase'; import { TaskRunner } from './taskRunner'; import { detectChangedTestFiles } from './vcs'; @@ -96,16 +96,12 @@ export class TestRun { readonly loadFileFilters: Matcher[] = []; readonly preOnlyTestFilters: TestCaseFilter[] = []; readonly postShardTestFilters: TestCaseFilter[] = []; - readonly projectClosureIds: Set; constructor(config: FullConfigInternal, reporter: InternalReporter, options?: TestRunOptions) { this.config = config; this.options = options ?? {}; this.reporter = reporter; this.filteredProjects = filterProjects(config.projects, this.options.projectFilter); - this.projectClosureIds = new Set(); - for (const project of buildProjectsClosure(this.filteredProjects).keys()) - this.projectClosureIds.add(project.id); } onTestPaused(params: TestPausedParams) { @@ -195,14 +191,12 @@ export function createReportBeginTask(): Task { export function createPluginSetupTasks(config: FullConfigInternal): Task[] { return config.plugins.map(plugin => ({ title: 'plugin setup', - setup: async testRun => { - if (plugin.projectId && !testRun.projectClosureIds.has(plugin.projectId)) - return; + setup: async ({ reporter }) => { if (typeof plugin.factory === 'function') plugin.instance = await plugin.factory(); else plugin.instance = plugin.factory; - await plugin.instance?.setup?.(config.config, config.configDir, testRun.reporter); + await plugin.instance?.setup?.(config.config, config.configDir, reporter); }, teardown: async () => { await plugin.instance?.teardown?.(); @@ -214,8 +208,6 @@ function createPluginBeginTask(plugin: TestRunnerPluginRegistration): Task { - if (plugin.projectId && !testRun.projectClosureIds.has(plugin.projectId)) - return; await plugin.instance?.begin?.(testRun.rootSuite!); }, teardown: async () => { diff --git a/packages/playwright/src/runner/testRunner.ts b/packages/playwright/src/runner/testRunner.ts index 254b1d243ca0f..31a09983955d4 100644 --- a/packages/playwright/src/runner/testRunner.ts +++ b/packages/playwright/src/runner/testRunner.ts @@ -393,7 +393,7 @@ export class TestRunner extends EventEmitter { const config = await configLoader.loadConfig(this.configLocation, overrides); // Preserve plugin instances between setup and build. if (!this._plugins) { - config.plugins.push(...webServerPluginsForConfig(config)); + webServerPluginsForConfig(config).forEach(p => config.plugins.push({ factory: p })); addGitCommitInfoPlugin(config); this._plugins = config.plugins || []; } else { @@ -448,7 +448,7 @@ export async function runAllTestsWithConfig(config: FullConfigInternal, options: addGitCommitInfoPlugin(config); // Legacy webServer support. - config.plugins.push(...webServerPluginsForConfig(config)); + webServerPluginsForConfig(config).forEach(p => config.plugins.push({ factory: p })); const filteredProjects = filterProjects(config.projects, options.projectFilter); const reporters = await createReporters(config, options.listMode ? 'list' : 'test', undefined, options); diff --git a/packages/playwright/types/test.d.ts b/packages/playwright/types/test.d.ts index 437e29ffa7b30..c2037da8f806b 100644 --- a/packages/playwright/types/test.d.ts +++ b/packages/playwright/types/test.d.ts @@ -129,54 +129,6 @@ interface TestProject { * all projects. */ use?: UseOptions; - /** - * Launch a development web server (or multiple) before running tests in this project. See - * [testConfig.webServer](https://playwright.dev/docs/api/class-testconfig#test-config-web-server) for the shape of - * each entry. - * - * A per-project `webServer` is only launched when the project is selected (either directly via `--project` or - * indirectly through dependencies). This is useful when only a subset of your projects need a local backend, while - * others run against a deployed environment. - * - * Per-project web servers are launched in addition to any top-level - * [testConfig.webServer](https://playwright.dev/docs/api/class-testconfig#test-config-web-server). - * - * **Usage** - * - * ```js - * // playwright.config.ts - * import { defineConfig } from '@playwright/test'; - * - * export default defineConfig({ - * projects: [ - * { - * name: 'functional', - * grepInvert: /@smoke/, - * use: { baseURL: 'http://localhost:3000' }, - * webServer: [ - * { - * command: 'npm run start', - * url: 'http://localhost:3000', - * reuseExistingServer: !process.env.CI, - * }, - * { - * command: 'npm run mock-server', - * port: 3001, - * reuseExistingServer: !process.env.CI, - * }, - * ], - * }, - * { - * name: 'smoke', - * grep: /@smoke/, - * use: { baseURL: 'https://production.app.com' }, - * }, - * ], - * }); - * ``` - * - */ - webServer?: TestConfigWebServer | TestConfigWebServer[]; /** * List of projects that need to run before any test in this project runs. Dependencies can be useful for configuring * the global setup actions in a way that every action is in a form of a test. Passing `--no-deps` argument ignores diff --git a/tests/playwright-test/web-server.spec.ts b/tests/playwright-test/web-server.spec.ts index 364bc242a0a52..13a3952d6deb4 100644 --- a/tests/playwright-test/web-server.spec.ts +++ b/tests/playwright-test/web-server.spec.ts @@ -931,7 +931,7 @@ test('should throw helpful error when command is empty', async ({ runInlineTest `, }, undefined); expect(result.exitCode).toBe(1); - expect(result.output).toContain('webServer[0].command must be a non-empty string'); + expect(result.output).toContain('config.webServer.command cannot be empty'); }); for (const stdio of ['stdout', 'stderr']) { @@ -1017,190 +1017,3 @@ for (const stdio of ['stdout', 'stderr']) { expect(result.output).toContain('My server port is 123'); }); } - -test.describe('per-project webServer', () => { - test('should launch only servers for the selected project', async ({ runInlineTest }, { workerIndex }) => { - const portA = workerIndex * 4 + 10600; - const portB = workerIndex * 4 + 10601; - const result = await runInlineTest({ - 'test.spec.ts': ` - import { test, expect } from '@playwright/test'; - test('connect', async ({ baseURL, page }) => { - await page.goto('/hello'); - expect(await page.textContent('body')).toBe('hello'); - }); - `, - 'playwright.config.ts': ` - module.exports = { - projects: [ - { - name: 'with-server', - use: { baseURL: 'http://localhost:${portA}' }, - webServer: { - command: 'node ${JSON.stringify(SIMPLE_SERVER_PATH)} ${portA}', - url: 'http://localhost:${portA}/hello', - name: 'ServerA', - }, - }, - { - name: 'no-server', - use: { baseURL: 'http://localhost:${portB}' }, - webServer: { - command: 'node ${JSON.stringify(SIMPLE_SERVER_PATH)} ${portB}', - url: 'http://localhost:${portB}/hello', - name: 'ServerB', - }, - }, - ], - }; - `, - }, { project: 'with-server' }); - expect(result.exitCode).toBe(0); - expect(result.passed).toBe(1); - expect(result.output).toContain('[ServerA]'); - expect(result.output).not.toContain('[ServerB]'); - }); - - test('should launch a per-project server for a project running as dependency', async ({ runInlineTest }, { workerIndex }) => { - const port = workerIndex * 2 + 10610; - const result = await runInlineTest({ - 'setup.spec.ts': ` - import { test, expect } from '@playwright/test'; - test('warm up', async ({ baseURL, request }) => { - const r = await request.get('/hello'); - expect(await r.text()).toBe('hello'); - }); - `, - 'test.spec.ts': ` - import { test, expect } from '@playwright/test'; - test('use', async ({ baseURL, page }) => { - await page.goto('/hello'); - expect(await page.textContent('body')).toBe('hello'); - }); - `, - 'playwright.config.ts': ` - module.exports = { - use: { baseURL: 'http://localhost:${port}' }, - projects: [ - { - name: 'setup', - testMatch: /setup\\.spec\\.ts/, - webServer: { - command: 'node ${JSON.stringify(SIMPLE_SERVER_PATH)} ${port}', - url: 'http://localhost:${port}/hello', - name: 'SetupServer', - }, - }, - { - name: 'main', - testMatch: /test\\.spec\\.ts/, - dependencies: ['setup'], - }, - ], - }; - `, - }, { project: 'main' }); - expect(result.exitCode).toBe(0); - expect(result.passed).toBe(2); - expect(result.output).toContain('[SetupServer]'); - }); - - test('should launch top-level webServer regardless of selected project', async ({ runInlineTest }, { workerIndex }) => { - const topPort = workerIndex * 2 + 10620; - const projPort = workerIndex * 2 + 10621; - const result = await runInlineTest({ - 'test.spec.ts': ` - import { test, expect } from '@playwright/test'; - test('check both', async ({ request }) => { - expect((await (await request.get('http://localhost:${topPort}/hello')).text())).toBe('hello'); - }); - `, - 'playwright.config.ts': ` - module.exports = { - webServer: { - command: 'node ${JSON.stringify(SIMPLE_SERVER_PATH)} ${topPort}', - url: 'http://localhost:${topPort}/hello', - name: 'TopServer', - }, - projects: [ - { - name: 'A', - webServer: { - command: 'node ${JSON.stringify(SIMPLE_SERVER_PATH)} ${projPort}', - url: 'http://localhost:${projPort}/hello', - name: 'ProjAServer', - }, - }, - { - name: 'B', - }, - ], - }; - `, - }, { project: 'B' }); - expect(result.exitCode).toBe(0); - expect(result.passed).toBe(1); - expect(result.output).toContain('[TopServer]'); - expect(result.output).not.toContain('[ProjAServer]'); - }); - - test('should accept an array of webServer per project', async ({ runInlineTest }, { workerIndex }) => { - const port1 = workerIndex * 2 + 10630; - const port2 = workerIndex * 2 + 10631; - const result = await runInlineTest({ - 'test.spec.ts': ` - import { test, expect } from '@playwright/test'; - test('connect', async ({ request }) => { - expect(await (await request.get('http://localhost:${port1}/hello')).text()).toBe('hello'); - expect(await (await request.get('http://localhost:${port2}/hello')).text()).toBe('hello'); - }); - `, - 'playwright.config.ts': ` - module.exports = { - projects: [ - { - name: 'A', - webServer: [ - { - command: 'node ${JSON.stringify(SIMPLE_SERVER_PATH)} ${port1}', - url: 'http://localhost:${port1}/hello', - name: 'ServerOne', - }, - { - command: 'node ${JSON.stringify(SIMPLE_SERVER_PATH)} ${port2}', - url: 'http://localhost:${port2}/hello', - name: 'ServerTwo', - }, - ], - }, - ], - }; - `, - }); - expect(result.exitCode).toBe(0); - expect(result.passed).toBe(1); - expect(result.output).toContain('[ServerOne]'); - expect(result.output).toContain('[ServerTwo]'); - }); - - test('should validate project.webServer.command', async ({ runInlineTest }) => { - const result = await runInlineTest({ - 'test.spec.ts': ` - import { test } from '@playwright/test'; - test('pass', async ({}) => {}); - `, - 'playwright.config.ts': ` - module.exports = { - projects: [ - { - name: 'A', - webServer: { command: '', url: 'http://localhost:1' }, - }, - ], - }; - `, - }); - expect(result.exitCode).toBe(1); - expect(result.output).toContain('webServer.command must be a non-empty string'); - }); -}); diff --git a/utils/generate_types/overrides-test.d.ts b/utils/generate_types/overrides-test.d.ts index 5710bd4b2e091..7087c2dea2299 100644 --- a/utils/generate_types/overrides-test.d.ts +++ b/utils/generate_types/overrides-test.d.ts @@ -50,7 +50,6 @@ type UseOptions = Partial & Partial; interface TestProject { use?: UseOptions; - webServer?: TestConfigWebServer | TestConfigWebServer[]; } export interface Project extends TestProject {