From b1d22ef15d21fb5c2db27266b3a9f294f9f5ba5f Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Sun, 8 Mar 2026 08:20:36 -0700 Subject: [PATCH 1/2] chore(cli): implement attach to server (#39550) --- .github/workflows/tests_mcp.yml | 2 +- packages/playwright-core/package.json | 1 + packages/playwright-core/src/DEPS.list | 3 + .../playwright-core/src/cli/client/DEPS.list | 1 + .../playwright-core/src/cli/client/program.ts | 38 ++++- .../playwright-core/src/cli/client/session.ts | 2 + .../playwright-core/src/cli/daemon/DEPS.list | 1 + .../src/cli/daemon/commands.ts | 1 + .../playwright-core/src/cli/daemon/program.ts | 2 + .../playwright-core/src/client/android.ts | 4 +- .../playwright-core/src/client/browser.ts | 2 + .../playwright-core/src/client/browserType.ts | 58 +------ .../src/client/{webSocket.ts => connect.ts} | 68 +++++++- packages/playwright-core/src/client/types.ts | 3 +- packages/playwright-core/src/mcp/DEPS.list | 1 + .../playwright-core/src/mcp/browserFactory.ts | 20 ++- .../playwright-core/src/protocol/validator.ts | 2 +- packages/playwright-core/src/server/DEPS.list | 1 + .../src/server/android/android.ts | 2 +- .../playwright-core/src/server/browser.ts | 46 ++---- .../playwright-core/src/server/browserType.ts | 2 +- .../src/server/chromium/chromium.ts | 2 +- .../dispatchers/browserContextDispatcher.ts | 3 +- .../server/dispatchers/browserDispatcher.ts | 8 +- .../src/server/electron/electron.ts | 2 +- .../src/server/recorder/recorderApp.ts | 9 +- .../playwright-core/src/serverRegistry.ts | 149 ++++++++++++++++++ packages/protocol/src/channels.d.ts | 2 +- packages/protocol/src/protocol.yml | 7 +- tests/library/browser-server.spec.ts | 12 +- tests/mcp/cli-session.spec.ts | 80 ++++++++-- utils/lint_tests.js | 4 + 32 files changed, 407 insertions(+), 131 deletions(-) rename packages/playwright-core/src/client/{webSocket.ts => connect.ts} (53%) create mode 100644 packages/playwright-core/src/serverRegistry.ts diff --git a/.github/workflows/tests_mcp.yml b/.github/workflows/tests_mcp.yml index 2128aa608ec8f..506946d4c5635 100644 --- a/.github/workflows/tests_mcp.yml +++ b/.github/workflows/tests_mcp.yml @@ -27,7 +27,7 @@ env: jobs: test_mcp: - name: ${{ matrix.os }} + name: ${{ matrix.os }} - ${{ matrix.shardIndex }}/${{ matrix.shardTotal }} # Used for authentication of flakiness upload environment: ${{ github.event_name == 'push' && 'allow-uploading-flakiness-results' || null }} strategy: diff --git a/packages/playwright-core/package.json b/packages/playwright-core/package.json index f8134d33d0b47..560121b58200e 100644 --- a/packages/playwright-core/package.json +++ b/packages/playwright-core/package.json @@ -25,6 +25,7 @@ "./lib/outofprocess": "./lib/outofprocess.js", "./lib/cli/program": "./lib/cli/program.js", "./lib/cli/client/program": "./lib/cli/client/program.js", + "./lib/client/connect": "./lib/client/connect.js", "./lib/mcpBundle": "./lib/mcpBundle.js", "./lib/mcp/exports": "./lib/mcp/exports.js", "./lib/tools/exports": "./lib/tools/exports.js", diff --git a/packages/playwright-core/src/DEPS.list b/packages/playwright-core/src/DEPS.list index c4f50cb22e158..292d724854d67 100644 --- a/packages/playwright-core/src/DEPS.list +++ b/packages/playwright-core/src/DEPS.list @@ -27,3 +27,6 @@ protocol/ utils/ utils/isomorphic server/utils + +[serverRegistry.ts] +"strict" diff --git a/packages/playwright-core/src/cli/client/DEPS.list b/packages/playwright-core/src/cli/client/DEPS.list index 0f79a6e8a2f2d..670e75657986b 100644 --- a/packages/playwright-core/src/cli/client/DEPS.list +++ b/packages/playwright-core/src/cli/client/DEPS.list @@ -2,6 +2,7 @@ "strict" ./session.ts ./registry.ts +../../serverRegistry.ts [session.ts] "strict" diff --git a/packages/playwright-core/src/cli/client/program.ts b/packages/playwright-core/src/cli/client/program.ts index 9fcbacbf6d5bf..7d3708393d969 100644 --- a/packages/playwright-core/src/cli/client/program.ts +++ b/packages/playwright-core/src/cli/client/program.ts @@ -25,9 +25,11 @@ import os from 'os'; import path from 'path'; import { createClientInfo, Registry, resolveSessionName } from './registry'; import { Session, renderResolvedConfig } from './session'; +import { serverRegistry } from '../../serverRegistry'; import type { Config } from '../../mcp/config.d'; import type { ClientInfo, SessionFile } from './registry'; +import type { BrowserDescriptor } from '../../serverRegistry'; type MinimistArgs = { _: string[]; @@ -336,12 +338,24 @@ async function killAllDaemons(): Promise { async function listSessions(registry: Registry, clientInfo: ClientInfo, all: boolean): Promise { if (all) { const entries = registry.entryMap(); - if (entries.size === 0) { + const serverEntries = await serverRegistry.list({ gc: true }); + if (entries.size === 0 && serverEntries.size === 0) { console.log('No browsers found.'); return; } + + if (entries.size) + console.log('### Browsers'); for (const [workspace, list] of entries) - await gcAndPrintSessions(clientInfo, list.map(entry => new Session(entry)), `${workspace}:`); + await gcAndPrintSessions(clientInfo, list.map(entry => new Session(entry)), `${path.relative(process.cwd(), workspace) || '/'}:`); + + if (serverEntries.size) { + if (entries.size) + console.log(''); + console.log('### Browser servers available for attach'); + } + for (const [workspace, list] of serverEntries) + await gcAndPrintBrowserSessions(workspace, list); } else { console.log('### Browsers'); const entries = registry.entries(clientInfo); @@ -377,6 +391,26 @@ async function gcAndPrintSessions(clientInfo: ClientInfo, sessions: Session[], h console.log(' (no browsers)'); } +async function gcAndPrintBrowserSessions(workspace: string, list: BrowserDescriptor[]) { + if (!list.length) + return; + + if (workspace) + console.log(`${path.relative(process.cwd(), workspace) || '/'}:`); + + for (const descriptor of list) { + const text: string[] = []; + text.push(`- browser "${descriptor.title}":`); + text.push(` - browser: ${descriptor.browser.browserName}`); + text.push(` - version: v${descriptor.playwrightVersion}`); + text.push(` - run \`playwright-cli open --attach "${descriptor.title}"\` to attach`); + console.log(text.join('\n')); + } + + if (!list.length) + console.log(' (no browsers)'); +} + async function renderSessionStatus(clientInfo: ClientInfo, session: Session) { const text: string[] = []; const config = session.config; diff --git a/packages/playwright-core/src/cli/client/session.ts b/packages/playwright-core/src/cli/client/session.ts index fc874c5aa2607..029a059664e67 100644 --- a/packages/playwright-core/src/cli/client/session.ts +++ b/packages/playwright-core/src/cli/client/session.ts @@ -149,6 +149,8 @@ export class Session { args.push(`--profile=${cliArgs.profile}`); if (cliArgs.config) args.push(`--config=${cliArgs.config}`); + if (cliArgs.attach) + args.push(`--attach=${cliArgs.attach}`); const child = spawn(process.execPath, args, { detached: true, diff --git a/packages/playwright-core/src/cli/daemon/DEPS.list b/packages/playwright-core/src/cli/daemon/DEPS.list index 7a50ccf92bf40..8f52042492225 100644 --- a/packages/playwright-core/src/cli/daemon/DEPS.list +++ b/packages/playwright-core/src/cli/daemon/DEPS.list @@ -7,3 +7,4 @@ ../../mcpBundle.ts ../../mcp/ ../../server/utils/ +../../serverRegistry.ts diff --git a/packages/playwright-core/src/cli/daemon/commands.ts b/packages/playwright-core/src/cli/daemon/commands.ts index 1987ef8486a97..42f256e8f8cab 100644 --- a/packages/playwright-core/src/cli/daemon/commands.ts +++ b/packages/playwright-core/src/cli/daemon/commands.ts @@ -47,6 +47,7 @@ const open = declareCommand({ headed: z.boolean().optional().describe('Run browser in headed mode'), persistent: z.boolean().optional().describe('Use persistent browser profile'), profile: z.string().optional().describe('Use persistent browser profile, store profile in specified directory.'), + attach: z.string().optional().describe('Attach to a running Playwright browser by name or endpoint'), }), toolName: ({ url }) => url ? 'browser_navigate' : 'browser_snapshot', toolParams: ({ url }) => ({ url: url || 'about:blank' }), diff --git a/packages/playwright-core/src/cli/daemon/program.ts b/packages/playwright-core/src/cli/daemon/program.ts index 69784e23897f7..77be4232e48ce 100644 --- a/packages/playwright-core/src/cli/daemon/program.ts +++ b/packages/playwright-core/src/cli/daemon/program.ts @@ -38,6 +38,7 @@ export function decorateCLICommand(command: Command, version: string) { .option('--persistent', 'use a persistent browser context') .option('--profile ', 'path to the user data dir') .option('--config ', 'path to the config file') + .option('--attach ', 'attach to a running Playwright browser by name or endpoint') .action(async (sessionName: string, options: any) => { setupExitWatchdog(); @@ -84,6 +85,7 @@ export async function resolveCLIConfig(clientInfo: ClientInfo, sessionName: stri outputMode: 'file', snapshotMode: 'full', }); + daemonOverrides.browser!.remoteEndpoint = options.attach; const envOverrides = configUtils.configFromEnv(); const configFile = envOverrides.configFile ?? daemonOverrides.configFile; diff --git a/packages/playwright-core/src/client/android.ts b/packages/playwright-core/src/client/android.ts index 8e4c900b512b7..23b0036f7409c 100644 --- a/packages/playwright-core/src/client/android.ts +++ b/packages/playwright-core/src/client/android.ts @@ -24,7 +24,7 @@ import { TimeoutSettings } from './timeoutSettings'; import { isRegExp, isString } from '../utils/isomorphic/rtti'; import { monotonicTime } from '../utils/isomorphic/time'; import { raceAgainstDeadline } from '../utils/isomorphic/timeoutRunner'; -import { connectOverWebSocket } from './webSocket'; +import { connectToEndpoint } from './connect'; import type { Page } from './page'; import type * as types from './types'; @@ -71,7 +71,7 @@ export class Android extends ChannelOwner implements ap const deadline = options.timeout ? monotonicTime() + options.timeout : 0; const headers = { 'x-playwright-browser': 'android', ...options.headers }; const connectParams: channels.LocalUtilsConnectParams = { endpoint, headers, slowMo: options.slowMo, timeout: options.timeout || 0 }; - const connection = await connectOverWebSocket(this._connection, connectParams); + const connection = await connectToEndpoint(this._connection, connectParams); let device: AndroidDevice; connection.on('close', () => { diff --git a/packages/playwright-core/src/client/browser.ts b/packages/playwright-core/src/client/browser.ts index 2cf3b3cac171a..0ab453827a237 100644 --- a/packages/playwright-core/src/client/browser.ts +++ b/packages/playwright-core/src/client/browser.ts @@ -37,6 +37,7 @@ export class Browser extends ChannelOwner implements ap _options: LaunchOptions = {}; _userDataDir: string | undefined; readonly _name: string; + readonly _browserName: 'chromium' | 'webkit' | 'firefox'; private _path: string | undefined; _closeReason: string | undefined; @@ -47,6 +48,7 @@ export class Browser extends ChannelOwner implements ap constructor(parent: ChannelOwner, type: string, guid: string, initializer: channels.BrowserInitializer) { super(parent, type, guid, initializer); this._name = initializer.name; + this._browserName = initializer.browserName; this._channel.on('context', ({ context }) => this._didCreateContext(BrowserContext.from(context))); this._channel.on('close', () => this._didClose()); this._closedPromise = new Promise(f => this.once(Events.Browser.Disconnected, f)); diff --git a/packages/playwright-core/src/client/browserType.ts b/packages/playwright-core/src/client/browserType.ts index 44da774a51690..550518ba32109 100644 --- a/packages/playwright-core/src/client/browserType.ts +++ b/packages/playwright-core/src/client/browserType.ts @@ -18,12 +18,9 @@ import { Browser } from './browser'; import { BrowserContext, prepareBrowserContextParams } from './browserContext'; import { ChannelOwner } from './channelOwner'; import { envObjectToArray } from './clientHelper'; -import { Events } from './events'; import { assert } from '../utils/isomorphic/assert'; import { headersObjectToArray } from '../utils/isomorphic/headers'; -import { monotonicTime } from '../utils/isomorphic/time'; -import { raceAgainstDeadline } from '../utils/isomorphic/timeoutRunner'; -import { connectOverWebSocket } from './webSocket'; +import { connectToBrowser } from './connect'; import { TimeoutSettings } from './timeoutSettings'; import type { Playwright } from './playwright'; @@ -123,62 +120,19 @@ export class BrowserType extends ChannelOwner imple connect(options: api.ConnectOptions & { wsEndpoint: string }): Promise; connect(endpoint: string, options?: api.ConnectOptions): Promise; - async connect(optionsOrEndpoint: string | (api.ConnectOptions & { wsEndpoint?: string, pipeName?: string }), options?: api.ConnectOptions): Promise{ + async connect(optionsOrEndpoint: string | (api.ConnectOptions & { wsEndpoint?: string }), options?: api.ConnectOptions): Promise{ if (typeof optionsOrEndpoint === 'string') return await this._connect({ ...options, endpoint: optionsOrEndpoint }); assert(optionsOrEndpoint.wsEndpoint, 'options.wsEndpoint is required'); - return await this._connect(optionsOrEndpoint); + return await this._connect({ ...options, endpoint: optionsOrEndpoint.wsEndpoint }); } async _connect(params: ConnectOptions): Promise { const logger = params.logger; return await this._wrapApiCall(async () => { - const deadline = params.timeout ? monotonicTime() + params.timeout : 0; - const headers = { 'x-playwright-browser': this.name(), ...params.headers }; - const connectParams: channels.LocalUtilsConnectParams = { - endpoint: params.endpoint!, - headers, - exposeNetwork: params.exposeNetwork, - slowMo: params.slowMo, - timeout: params.timeout || 0, - }; - if ((params as any).__testHookRedirectPortForwarding) - connectParams.socksProxyRedirectPortForTest = (params as any).__testHookRedirectPortForwarding; - const connection = await connectOverWebSocket(this._connection, connectParams); - let browser: Browser; - connection.on('close', () => { - // Emulate all pages, contexts and the browser closing upon disconnect. - for (const context of browser?.contexts() || []) { - for (const page of context.pages()) - page._onClose(); - context._onClose(); - } - setTimeout(() => browser?._didClose(), 0); - }); - - const result = await raceAgainstDeadline(async () => { - // For tests. - if ((params as any).__testHookBeforeCreateBrowser) - await (params as any).__testHookBeforeCreateBrowser(); - - const playwright = await connection!.initializePlaywright(); - if (!playwright._initializer.preLaunchedBrowser) { - connection.close(); - throw new Error('Malformed endpoint. Did you use BrowserType.launchServer method?'); - } - playwright.selectors = this._playwright.selectors; - browser = Browser.from(playwright._initializer.preLaunchedBrowser!); - browser._connectToBrowserType(this, {}, logger); - browser._shouldCloseConnectionOnClose = true; - browser.on(Events.Browser.Disconnected, () => connection.close()); - return browser; - }, deadline); - if (!result.timedOut) { - return result.result; - } else { - connection.close(); - throw new Error(`Timeout ${params.timeout}ms exceeded`); - } + const browser = await connectToBrowser(this._playwright, { browserName: this.name(), ...params }); + browser._connectToBrowserType(this, {}, logger); + return browser; }); } diff --git a/packages/playwright-core/src/client/webSocket.ts b/packages/playwright-core/src/client/connect.ts similarity index 53% rename from packages/playwright-core/src/client/webSocket.ts rename to packages/playwright-core/src/client/connect.ts index 7afc749573b21..e22200975cdcb 100644 --- a/packages/playwright-core/src/client/webSocket.ts +++ b/packages/playwright-core/src/client/connect.ts @@ -14,13 +14,69 @@ * limitations under the License. */ +import { monotonicTime } from '../utils/isomorphic/time'; +import { raceAgainstDeadline } from '../utils/isomorphic/timeoutRunner'; +import { Browser } from './browser'; import { ChannelOwner } from './channelOwner'; import { Connection } from './connection'; +import { Events } from './events'; -import type { HeadersArray } from './types'; +import type * as playwright from '../..'; +import type { Playwright } from './playwright'; +import type { ConnectOptions, HeadersArray } from './types'; import type * as channels from '@protocol/channels'; +import type { BrowserDescriptor } from '../serverRegistry'; + +export async function connectToBrowser(playwright: Playwright, params: ConnectOptions): Promise { + const deadline = params.timeout ? monotonicTime() + params.timeout : 0; + const nameParam = params.browserName ? { 'x-playwright-browser': params.browserName } : {}; + const headers = { ...nameParam, ...params.headers }; + const connectParams: channels.LocalUtilsConnectParams = { + endpoint: params.endpoint!, + headers, + exposeNetwork: params.exposeNetwork, + slowMo: params.slowMo, + timeout: params.timeout || 0, + }; + if ((params as any).__testHookRedirectPortForwarding) + connectParams.socksProxyRedirectPortForTest = (params as any).__testHookRedirectPortForwarding; + const connection = await connectToEndpoint(playwright._connection, connectParams); + let browser: Browser; + connection.on('close', () => { + // Emulate all pages, contexts and the browser closing upon disconnect. + for (const context of browser?.contexts() || []) { + for (const page of context.pages()) + page._onClose(); + context._onClose(); + } + setTimeout(() => browser?._didClose(), 0); + }); + + const result = await raceAgainstDeadline(async () => { + // For tests. + if ((params as any).__testHookBeforeCreateBrowser) + await (params as any).__testHookBeforeCreateBrowser(); + + const playwright = await connection!.initializePlaywright(); + if (!playwright._initializer.preLaunchedBrowser) { + connection.close(); + throw new Error('Malformed endpoint. Did you use BrowserType.launchServer method?'); + } + playwright.selectors = playwright.selectors; + browser = Browser.from(playwright._initializer.preLaunchedBrowser!); + browser._shouldCloseConnectionOnClose = true; + browser.on(Events.Browser.Disconnected, () => connection.close()); + return browser; + }, deadline); + if (!result.timedOut) { + return result.result; + } else { + connection.close(); + throw new Error(`Timeout ${params.timeout}ms exceeded`); + } +} -export async function connectOverWebSocket(parentConnection: Connection, params: channels.LocalUtilsConnectParams): Promise { +export async function connectToEndpoint(parentConnection: Connection, params: channels.LocalUtilsConnectParams): Promise { const localUtils = parentConnection.localUtils(); const transport = localUtils ? new JsonPipeTransport(localUtils) : new WebSocketTransport(); const connectHeaders = await transport.connect(params); @@ -45,6 +101,14 @@ export async function connectOverWebSocket(parentConnection: Connection, params: return connection; } +export async function connectToBrowserAcrossVersions(descriptor: BrowserDescriptor): Promise { + const pw = require(descriptor.playwrightLib); + const params: ConnectOptions = { endpoint: descriptor.pipeName! }; + const browser = await connectToBrowser(pw, params); + browser._connectToBrowserType(pw[descriptor.browser.browserName], {}, undefined); + return browser; +} + interface Transport { connect(params: channels.LocalUtilsConnectParams): Promise; send(message: any): Promise; diff --git a/packages/playwright-core/src/client/types.ts b/packages/playwright-core/src/client/types.ts index d926e9f8ccf2d..7191942df6695 100644 --- a/packages/playwright-core/src/client/types.ts +++ b/packages/playwright-core/src/client/types.ts @@ -96,7 +96,8 @@ export type LaunchOptions = Omit; export type ConnectOptions = { - endpoint?: string; + endpoint: string; + browserName?: string; headers?: { [key: string]: string; }; exposeNetwork?: string; slowMo?: number; diff --git a/packages/playwright-core/src/mcp/DEPS.list b/packages/playwright-core/src/mcp/DEPS.list index 31ee6692f7815..03d40d26e9173 100644 --- a/packages/playwright-core/src/mcp/DEPS.list +++ b/packages/playwright-core/src/mcp/DEPS.list @@ -11,3 +11,4 @@ ../server/utils/ ../client/ ../cli/ +../serverRegistry.ts diff --git a/packages/playwright-core/src/mcp/browserFactory.ts b/packages/playwright-core/src/mcp/browserFactory.ts index 284de9c3f1e02..df9ad6f531cd7 100644 --- a/packages/playwright-core/src/mcp/browserFactory.ts +++ b/packages/playwright-core/src/mcp/browserFactory.ts @@ -24,10 +24,13 @@ import { registryDirectory } from '../server/registry/index'; import { testDebug } from './log'; import { outputDir } from '../tools/context'; import { createExtensionBrowser } from './extensionContextFactory'; +import { connectToBrowser, connectToBrowserAcrossVersions } from '../client/connect'; +import { serverRegistry } from '../serverRegistry'; import type { FullConfig } from './config'; -import type { LaunchOptions, BrowserContextOptions } from '../client/types'; +import type { LaunchOptions, BrowserContextOptions, ConnectOptions } from '../client/types'; import type { ClientInfo } from './sdk/server'; +import type { Playwright } from '../client/playwright'; export async function createBrowser(config: FullConfig, clientInfo: ClientInfo): Promise { if (config.browser.remoteEndpoint) @@ -73,11 +76,16 @@ async function createCDPBrowser(config: FullConfig): Promise async function createRemoteBrowser(config: FullConfig): Promise { testDebug('create browser (remote)'); - const url = new URL(config.browser.remoteEndpoint!); - url.searchParams.set('browser', config.browser.browserName); - if (config.browser.launchOptions) - url.searchParams.set('launch-options', JSON.stringify(config.browser.launchOptions)); - return playwright[config.browser.browserName].connect(String(url)); + const descriptor = await serverRegistry.find(config.browser.remoteEndpoint!); + if (descriptor) + return await connectToBrowserAcrossVersions(descriptor); + + const endpoint = config.browser.remoteEndpoint!; + const params: ConnectOptions = { endpoint }; + const playwrightObject = playwright as Playwright; + const browser = await connectToBrowser(playwrightObject, params); + browser._connectToBrowserType(playwrightObject[browser._browserName], {}, undefined); + return browser; } async function createPersistentBrowser(config: FullConfig, clientInfo: ClientInfo): Promise { diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index 2a31b6c52183d..1a64a527bc56c 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -643,6 +643,7 @@ scheme.BrowserTypeConnectOverCDPTransportResult = tObject({ scheme.BrowserInitializer = tObject({ version: tString, name: tString, + browserName: tEnum(['chromium', 'firefox', 'webkit']), }); scheme.BrowserContextEvent = tObject({ context: tChannel(['BrowserContext']), @@ -855,7 +856,6 @@ scheme.ElectronApplicationWaitForEventInfoResult = tType('EventTargetWaitForEven scheme.AndroidDeviceWaitForEventInfoResult = tType('EventTargetWaitForEventInfoResult'); scheme.PageAgentWaitForEventInfoResult = tType('EventTargetWaitForEventInfoResult'); scheme.BrowserContextInitializer = tObject({ - isChromium: tBoolean, requestContext: tChannel(['APIRequestContext']), tracing: tChannel(['Tracing']), options: tObject({ diff --git a/packages/playwright-core/src/server/DEPS.list b/packages/playwright-core/src/server/DEPS.list index 279bef3ef86a1..92396bb7fe859 100644 --- a/packages/playwright-core/src/server/DEPS.list +++ b/packages/playwright-core/src/server/DEPS.list @@ -27,3 +27,4 @@ [browser.ts] ../remote/ +../serverRegistry.ts diff --git a/packages/playwright-core/src/server/android/android.ts b/packages/playwright-core/src/server/android/android.ts index 81b3346dab751..db1616bd16c35 100644 --- a/packages/playwright-core/src/server/android/android.ts +++ b/packages/playwright-core/src/server/android/android.ts @@ -337,7 +337,7 @@ export class AndroidDevice extends SdkObject { }); const browserOptions: BrowserOptions = { name: 'clank', - isChromium: true, + browserType: 'chromium', slowMo: 0, persistent: { ...options, noDefaultViewport: true }, artifactsDir, diff --git a/packages/playwright-core/src/server/browser.ts b/packages/playwright-core/src/server/browser.ts index ddfa0c557dbce..f8ea207d682c5 100644 --- a/packages/playwright-core/src/server/browser.ts +++ b/packages/playwright-core/src/server/browser.ts @@ -27,7 +27,7 @@ import { ClientCertificatesProxy } from './socksClientCertificatesInterceptor'; import { PlaywrightPipeServer } from '../remote/playwrightPipeServer'; import { PlaywrightWebSocketServer } from '../remote/playwrightWebSocketServer'; import { createGuid } from './utils/crypto'; -import { defaultRegistryDirectory } from './registry'; +import { serverRegistry } from '../serverRegistry'; import type * as types from './types'; import type { ProxySettings } from './types'; @@ -46,7 +46,7 @@ export interface BrowserProcess { export type BrowserOptions = { name: string, - isChromium: boolean, + browserType: 'chromium' | 'firefox' | 'webkit', channel?: string, artifactsDir: string; downloadsPath: string, @@ -207,8 +207,6 @@ export abstract class Browser extends SdkObject { } } -const packageVersion = require('../../package.json').version; - export class BrowserServer { private _browser: Browser; private _pipeServer?: PlaywrightPipeServer; @@ -236,47 +234,25 @@ export class BrowserServer { result.wsEndpoint = await this._wsServer.listen(0); } - await this._createDescriptor(title, result); + await serverRegistry.create(this._browser, { + title, + wsEndpoint: result.wsEndpoint, + pipeName: result.pipeName, + workspaceDir: options.workspaceDir, + }); return result; } async stop() { - await this._deleteDescriptor(); + await serverRegistry.delete(this._browser); + if (this._pipeSocketPath && process.platform !== 'win32') + await fs.promises.unlink(this._pipeSocketPath).catch(() => {}); await this._pipeServer?.close(); await this._wsServer?.close(); this._pipeServer = undefined; this._wsServer = undefined; } - private async _createDescriptor(title: string, result: { wsEndpoint?: string, pipeName?: string, workspaceDir?: string }) { - const file = this._descriptorPath(); - await fs.promises.mkdir(path.dirname(file), { recursive: true }); - const descriptor = { - version: packageVersion, - title, - browser: { - name: this._browser.options.name, - channel: this._browser.options.channel, - version: this._browser.version(), - }, - wsEndpoint: result.wsEndpoint ? result.wsEndpoint : undefined, - pipeName: result.pipeName ? result.pipeName : undefined, - workspaceDir: result.workspaceDir, - }; - await fs.promises.writeFile(file, JSON.stringify(descriptor), 'utf-8'); - } - - private async _deleteDescriptor() { - const file = this._descriptorPath(); - await fs.promises.unlink(file).catch(() => {}); - if (this._pipeSocketPath && process.platform !== 'win32') - await fs.promises.unlink(this._pipeSocketPath).catch(() => {}); - } - - private _descriptorPath() { - return path.join(defaultRegistryDirectory, 'browsers', this._browser.guid); - } - private async _socketPath() { const socketName = `${this._browser.guid.slice(0, 14)}.sock`; if (process.platform === 'win32') diff --git a/packages/playwright-core/src/server/browserType.ts b/packages/playwright-core/src/server/browserType.ts index fd54a8a6d8aad..d50d288503ecd 100644 --- a/packages/playwright-core/src/server/browserType.ts +++ b/packages/playwright-core/src/server/browserType.ts @@ -114,7 +114,7 @@ export abstract class BrowserType extends SdkObject { await progress.race((options as any).__testHookBeforeCreateBrowser()); const browserOptions: BrowserOptions = { name: this._name, - isChromium: this._name === 'chromium', + browserType: this._name, channel: options.channel, slowMo: options.slowMo, persistent, diff --git a/packages/playwright-core/src/server/chromium/chromium.ts b/packages/playwright-core/src/server/chromium/chromium.ts index d398a292e0995..04b6c01053ede 100644 --- a/packages/playwright-core/src/server/chromium/chromium.ts +++ b/packages/playwright-core/src/server/chromium/chromium.ts @@ -115,7 +115,7 @@ export class Chromium extends BrowserType { const browserOptions: BrowserOptions = { slowMo: options.slowMo, name: 'chromium', - isChromium: true, + browserType: 'chromium', persistent, browserProcess, protocolLogger: helper.debugProtocolLogger(), diff --git a/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts b/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts index bbc9bd2146f56..b9af6b5d33508 100644 --- a/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts @@ -73,7 +73,6 @@ export class BrowserContextDispatcher extends Dispatcher { - if (!this._object._browser.options.isChromium) + if (this._object._browser.options.browserType !== 'chromium') throw new Error(`CDP session is only available in Chromium`); if (!params.page && !params.frame || params.page && params.frame) throw new Error(`CDP session must be initiated with either Page or Frame, not none or both`); diff --git a/packages/playwright-core/src/server/dispatchers/browserDispatcher.ts b/packages/playwright-core/src/server/dispatchers/browserDispatcher.ts index a87f7af5c0caa..2c43bd951d80d 100644 --- a/packages/playwright-core/src/server/dispatchers/browserDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/browserDispatcher.ts @@ -40,7 +40,7 @@ export class BrowserDispatcher extends Dispatcher(); constructor(scope: BrowserTypeDispatcher, browser: Browser, options: BrowserDispatcherOptions = {}) { - super(scope, browser, 'Browser', { version: browser.version(), name: browser.options.name }); + super(scope, browser, 'Browser', { version: browser.version(), name: browser.options.name, browserName: browser.options.browserType }); this._options = options; if (!options.isolateContexts) { @@ -112,7 +112,7 @@ export class BrowserDispatcher extends Dispatcher { // Note: progress is ignored because this operation is not cancellable and should not block in the browser anyway. - if (!this._object.options.isChromium) + if (this._object.options.browserType !== 'chromium') throw new Error(`CDP session is only available in Chromium`); const crBrowser = this._object as CRBrowser; return { session: new CDPSessionDispatcher(this, await crBrowser.newBrowserCDPSession()) }; @@ -120,7 +120,7 @@ export class BrowserDispatcher extends Dispatcher { // Note: progress is ignored because this operation is not cancellable and should not block in the browser anyway. - if (!this._object.options.isChromium) + if (this._object.options.browserType !== 'chromium') throw new Error(`Tracing is only available in Chromium`); const crBrowser = this._object as CRBrowser; await crBrowser.startTracing(params.page ? (params.page as PageDispatcher)._object : undefined, params); @@ -128,7 +128,7 @@ export class BrowserDispatcher extends Dispatcher { // Note: progress is ignored because this operation is not cancellable and should not block in the browser anyway. - if (!this._object.options.isChromium) + if (this._object.options.browserType !== 'chromium') throw new Error(`Tracing is only available in Chromium`); const crBrowser = this._object as CRBrowser; return { artifact: ArtifactDispatcher.from(this, await crBrowser.stopTracing()) }; diff --git a/packages/playwright-core/src/server/electron/electron.ts b/packages/playwright-core/src/server/electron/electron.ts index 68991673bb183..3bdf770ae9fe6 100644 --- a/packages/playwright-core/src/server/electron/electron.ts +++ b/packages/playwright-core/src/server/electron/electron.ts @@ -267,7 +267,7 @@ export class Electron extends SdkObject { }; const browserOptions: BrowserOptions = { name: 'electron', - isChromium: true, + browserType: 'chromium', headful: true, persistent: contextOptions, browserProcess, diff --git a/packages/playwright-core/src/server/recorder/recorderApp.ts b/packages/playwright-core/src/server/recorder/recorderApp.ts index 52713cab70336..2b77fc5efcd02 100644 --- a/packages/playwright-core/src/server/recorder/recorderApp.ts +++ b/packages/playwright-core/src/server/recorder/recorderApp.ts @@ -202,6 +202,7 @@ export class RecorderApp { return; (inspectedContext as any)[recorderAppSymbol] = true; const sdkLanguage = inspectedContext._browser.sdkLanguage(); + const isChromium = inspectedContext._browser.options.browserType === 'chromium'; const headed = !!inspectedContext._browser.options.headful; const recorderPlaywright = (require('../playwright').createPlaywright as typeof import('../playwright').createPlaywright)({ sdkLanguage: 'javascript', isInternalPlaywright: true }); const { context: appContext, page } = await launchApp(recorderPlaywright.chromium, { @@ -213,9 +214,9 @@ export class RecorderApp { headless: !!process.env.PWTEST_CLI_HEADLESS || (isUnderTest() && !headed), cdpPort: isUnderTest() ? 0 : undefined, handleSIGINT: params.handleSIGINT, - executablePath: inspectedContext._browser.options.isChromium ? inspectedContext._browser.options.customExecutablePath : undefined, + executablePath: isChromium ? inspectedContext._browser.options.customExecutablePath : undefined, // Use the same channel as the inspected context to guarantee that the browser is installed. - channel: inspectedContext._browser.options.isChromium ? inspectedContext._browser.options.channel : undefined, + channel: isChromium ? inspectedContext._browser.options.channel : undefined, } }); const controller = new ProgressController(); @@ -228,8 +229,8 @@ export class RecorderApp { sdkLanguage: inspectedContext._browser.sdkLanguage(), wsEndpointForTest: inspectedContext._browser.options.wsEndpoint, headed: !!inspectedContext._browser.options.headful, - executablePath: inspectedContext._browser.options.isChromium ? inspectedContext._browser.options.customExecutablePath : undefined, - channel: inspectedContext._browser.options.isChromium ? inspectedContext._browser.options.channel : undefined, + executablePath: isChromium ? inspectedContext._browser.options.customExecutablePath : undefined, + channel: isChromium ? inspectedContext._browser.options.channel : undefined, ...params, }; diff --git a/packages/playwright-core/src/serverRegistry.ts b/packages/playwright-core/src/serverRegistry.ts new file mode 100644 index 0000000000000..8a1784a05080c --- /dev/null +++ b/packages/playwright-core/src/serverRegistry.ts @@ -0,0 +1,149 @@ +/** + * 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 fs from 'fs'; +import net from 'net'; +import path from 'path'; +import os from 'os'; + +import type { Browser } from './server/browser'; +import type { LaunchOptions } from './server/types'; +import type { BrowserName } from './server/registry/index'; + +const packageVersion = require('../package.json').version; + +export type BrowserInfo = { + title: string; + wsEndpoint?: string; + pipeName?: string; + workspaceDir?: string; +}; + +export type BrowserDescriptor = BrowserInfo & { + playwrightVersion: string; + playwrightLib: string; + browser: { + browserName: BrowserName; + launchOptions: LaunchOptions; + }; +}; + +type BrowserEntry = BrowserDescriptor & { + canConnect: boolean; + file: string; +}; + +class ServerRegistry { + async list(options?: { gc?: boolean }): Promise> { + const files = await fs.promises.readdir(this._browsersDir()).catch(() => []); + const result = new Map[]>(); + for (const file of files) { + try { + const filePath = path.join(this._browsersDir(), file); + const content = await fs.promises.readFile(filePath, 'utf-8'); + const descriptor: BrowserDescriptor = JSON.parse(content); + const key = descriptor.workspaceDir ?? ''; + let list = result.get(key); + if (!list) { + list = []; + result.set(key, list); + } + list.push(canConnect(descriptor).then(connectable => ({ ...descriptor, canConnect: connectable, file: filePath }))); + } catch { + } + } + + const resolvedResult = new Map(); + for (const [key, promises] of result) { + const entries = await Promise.all(promises); + if (options?.gc) { + for (const entry of entries) { + if (!entry.canConnect) + await fs.promises.unlink(entry.file).catch(() => {}); + } + } + const list = entries.filter(entry => entry.canConnect); + if (list.length) + resolvedResult.set(key, list); + } + return resolvedResult; + } + + async create(browser: Browser, info: BrowserInfo): Promise { + const file = path.join(this._browsersDir(), browser.guid); + await fs.promises.mkdir(this._browsersDir(), { recursive: true }); + const descriptor: BrowserDescriptor = { + playwrightVersion: packageVersion, + playwrightLib: require.resolve('..'), + title: info.title, + browser: { + browserName: browser.options.browserType, + launchOptions: browser.options.originalLaunchOptions, + }, + wsEndpoint: info.wsEndpoint, + pipeName: info.pipeName, + workspaceDir: info.workspaceDir, + }; + await fs.promises.writeFile(file, JSON.stringify(descriptor), 'utf-8'); + } + + async delete(browser: Browser): Promise { + const file = path.join(this._browsersDir(), browser.guid); + await fs.promises.unlink(file).catch(() => {}); + } + + async find(name: string): Promise { + const entries = await this.list(); + for (const [, browsers] of entries) { + for (const browser of browsers) { + if (browser.title === name) + return browser; + } + } + return null; + } + + private _browsersDir() { + return process.env.PLAYWRIGHT_SERVER_REGISTRY || registryDirectory; + } +} + +async function canConnect(descriptor: BrowserDescriptor): Promise { + if (descriptor.pipeName) { + return await new Promise(resolve => { + const socket = net.createConnection(descriptor.pipeName!, () => { + socket.destroy(); + resolve(true); + }); + socket.on('error', () => resolve(false)); + }); + } + return false; +} + +const defaultCacheDirectory = (() => { + if (process.platform === 'linux') + return process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache'); + if (process.platform === 'darwin') + return path.join(os.homedir(), 'Library', 'Caches'); + if (process.platform === 'win32') + return process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'); + throw new Error('Unsupported platform: ' + process.platform); +})(); + +const registryDirectory = path.join(defaultCacheDirectory, 'ms-playwright', 'b'); + +export const serverRegistry = new ServerRegistry(); diff --git a/packages/protocol/src/channels.d.ts b/packages/protocol/src/channels.d.ts index ed98799521805..b5b4c0407c12e 100644 --- a/packages/protocol/src/channels.d.ts +++ b/packages/protocol/src/channels.d.ts @@ -1154,6 +1154,7 @@ export interface BrowserTypeEvents { export type BrowserInitializer = { version: string, name: string, + browserName: 'chromium' | 'firefox' | 'webkit', }; export interface BrowserEventTarget { on(event: 'context', callback: (params: BrowserContextEvent) => void): this; @@ -1547,7 +1548,6 @@ export interface EventTargetEvents { // ----------- BrowserContext ----------- export type BrowserContextInitializer = { - isChromium: boolean, requestContext: APIRequestContextChannel, tracing: TracingChannel, options: { diff --git a/packages/protocol/src/protocol.yml b/packages/protocol/src/protocol.yml index a812775aee472..8114c4099d729 100644 --- a/packages/protocol/src/protocol.yml +++ b/packages/protocol/src/protocol.yml @@ -1024,6 +1024,12 @@ Browser: initializer: version: string name: string + browserName: + type: enum + literals: + - chromium + - firefox + - webkit commands: @@ -1182,7 +1188,6 @@ BrowserContext: extends: EventTarget initializer: - isChromium: boolean requestContext: APIRequestContext tracing: Tracing options: diff --git a/tests/library/browser-server.spec.ts b/tests/library/browser-server.spec.ts index d34e5cdc617e1..4d082134d0021 100644 --- a/tests/library/browser-server.spec.ts +++ b/tests/library/browser-server.spec.ts @@ -18,12 +18,15 @@ import fs from 'fs'; import path from 'path'; import { browserTest as it, expect } from '../config/browserTest'; -import { defaultRegistryDirectory } from '../../packages/playwright-core/lib/server/registry'; it.skip(({ mode }) => mode !== 'default'); +it.beforeEach(({}, testInfo) => { + process.env.PLAYWRIGHT_SERVER_REGISTRY = testInfo.outputPath('registry'); +}); + function descriptorPath(browser: any) { - return path.join(defaultRegistryDirectory, 'browsers', browser._guid); + return path.join(it.info().outputPath('registry'), browser._guid); } it('should start and stop pipe server', async ({ browserType, browser }) => { @@ -65,8 +68,9 @@ it('should write descriptor on start and remove on stop', async ({ browser }) => const descriptor = JSON.parse(fs.readFileSync(file, 'utf-8')); expect(descriptor.title).toBe('my-title'); - expect(descriptor.version).toBeTruthy(); - expect(descriptor.browser.name).toBeTruthy(); + expect(descriptor.playwrightVersion).toBeTruthy(); + expect(descriptor.playwrightLib).toBeTruthy(); + expect(descriptor.browser.browserName).toBeTruthy(); expect(descriptor.wsEndpoint).toBe(serverInfo.wsEndpoint); expect(descriptor.pipeName).toBe(serverInfo.pipeName); diff --git a/tests/mcp/cli-session.spec.ts b/tests/mcp/cli-session.spec.ts index 746e885ef7a18..ff51dc73531d4 100644 --- a/tests/mcp/cli-session.spec.ts +++ b/tests/mcp/cli-session.spec.ts @@ -18,6 +18,7 @@ import fs from 'fs'; import path from 'path'; import { test, expect, daemonFolder } from './cli-fixtures'; import { killProcessGroup } from '../config/commonFixtures'; +import playwright from '../../packages/playwright-core'; test('list', async ({ cli, server }) => { const { output: emptyOutput } = await cli('list'); @@ -193,11 +194,11 @@ test('list --all lists sessions from all workspaces', async ({ cli, server }, te const { output: allList } = await cli('list', '--all', { cwd: workspace1 }); // Should include both workspace folders and sessions - expect(allList).toContain(workspace1); + expect(allList).toContain('/:'); expect(allList).toContain('session1'); - expect(allList).toContain(workspace2); + expect(allList).toContain('..' + path.sep + 'workspace2:'); expect(allList).toContain('session2'); - expect(allList).toContain(workspace3); + expect(allList).toContain('..' + path.sep + 'workspace3:'); expect(allList).toContain('session3'); const rootDir = test.info().outputPath('daemon'); @@ -212,11 +213,11 @@ test('list --all lists sessions from all workspaces', async ({ cli, server }, te await cli('-s', 'session1', 'close', { cwd: workspace1 }); const { output: listTwo } = await cli('list', '--all', { cwd: workspace2 }); - expect(listTwo).not.toContain(workspace1); + expect(listTwo).not.toContain('workspace1'); expect(listTwo).not.toContain('session1'); - expect(listTwo).toContain(workspace2); + expect(listTwo).toContain('/:'); expect(listTwo).toContain('session2'); - expect(listTwo).toContain(workspace3); + expect(listTwo).toContain('workspace3'); expect(listTwo).toContain('session3'); const sessionFilesAfterClose = await getSessionFiles(); @@ -227,11 +228,11 @@ test('list --all lists sessions from all workspaces', async ({ cli, server }, te killProcessGroup(session3.pid); const { output: listOne } = await cli('list', '--all', { cwd: workspace2 }); - expect(listOne).not.toContain(workspace1); + expect(listOne).not.toContain('workspace1'); expect(listOne).not.toContain('session1'); - expect(listOne).toContain(workspace2); + expect(listOne).toContain('/:'); expect(listOne).toContain('session2'); - expect(listOne).not.toContain(workspace3); + expect(listOne).not.toContain('workspace3'); expect(listOne).not.toContain('session3'); const sessionFilesAfterList = await getSessionFiles(); @@ -277,3 +278,64 @@ test('older client with newer daemon - list shows incompatible warning', async ( expect(output).toContain('- default:'); expect(output).toContain('[incompatible please re-open]'); }); + +test.describe('browser server', () => { + test.beforeEach(async ({ mcpBrowser }, testInfo) => { + test.skip(!['chrome', 'chromium', 'webkit', 'firefox'].includes(mcpBrowser)); + process.env.PLAYWRIGHT_SERVER_REGISTRY = testInfo.outputPath('registry'); + }); + + test('list browser servers', async ({ cli, mcpBrowser }) => { + const browserName = mcpBrowser.replace('chrome', 'chromium'); + await using browser = await playwright[browserName].launch({ headless: true }); + await (browser as any)._startServer('foobar', { workspaceDir: 'workspace1' }); + const { output } = await cli('list', '--all'); + expect(output).toBe(`### Browser servers available for attach +workspace1: +- browser "foobar": + - browser: ${/* FIX browser._options */ mcpBrowser.replace('chrome', 'chromium')} + - version: ${version} + - run \`playwright-cli open --attach "foobar"\` to attach`); + }); + + test('attach to browser server', async ({ cli, mcpBrowser }) => { + const browserName = mcpBrowser.replace('chrome', 'chromium'); + await using browser = await playwright[browserName].launch({ headless: true }); + await (browser as any)._startServer('foobar', { workspaceDir: 'workspace1' }); + const { output: openOutput } = await cli('open', '--attach=foobar'); + expect(openOutput).toContain('### Browser `default` opened with pid'); + const { output: listOutput } = await cli('list', '--all'); + expect(listOutput).toBe(`### Browsers +/: +- default: + - status: open + - browser-type: ${/* FIX browser._options */ mcpBrowser.replace('chrome', 'chromium')} + - user-data-dir: + - headed: true + +### Browser servers available for attach +workspace1: +- browser "foobar": + - browser: ${/* FIX browser._options */ mcpBrowser.replace('chrome', 'chromium')} + - version: ${version} + - run \`playwright-cli open --attach "foobar"\` to attach`); + }); + + test('detach from browser server', async ({ cli, mcpBrowser }) => { + const browserName = mcpBrowser.replace('chrome', 'chromium'); + await using browser = await playwright[browserName].launch({ headless: true }); + await (browser as any)._startServer('foobar', { workspaceDir: 'workspace1' }); + const { output: openOutput } = await cli('open', '--attach=foobar'); + expect(openOutput).toContain('### Browser `default` opened with pid'); + await cli('close'); + const { output: listOutput } = await cli('list', '--all'); + expect(listOutput).toBe(`### Browser servers available for attach +workspace1: +- browser \"foobar\": + - browser: ${/* FIX browser._options */ mcpBrowser.replace('chrome', 'chromium')} + - version: ${version} + - run \`playwright-cli open --attach \"foobar\"\` to attach`); + }); +}); + +const version = 'v' + require('../../packages/playwright-core/package.json').version; diff --git a/utils/lint_tests.js b/utils/lint_tests.js index fd008b2335f71..06b4f6d0f4d8a 100644 --- a/utils/lint_tests.js +++ b/utils/lint_tests.js @@ -28,6 +28,10 @@ try { stdio: ['ignore', 'ignore', 'inherit'], cwd: path.join(__dirname, '..'), }); + execSync('npm run test-mcp -- --list --forbid-only', { + stdio: ['ignore', 'ignore', 'inherit'], + cwd: path.join(__dirname, '..'), + }); } catch (e) { process.exit(1); } From e37438ade39049e1ab8f404e0354b7749b8f871a Mon Sep 17 00:00:00 2001 From: Natalie <133613092+natccc@users.noreply.github.com> Date: Sun, 8 Mar 2026 16:29:15 +0000 Subject: [PATCH 2/2] docs: fix outdated tocList text in POM page object model example (#39557) --- docs/src/pom.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/src/pom.md b/docs/src/pom.md index 6dcc683802726..4c88f82853cba 100644 --- a/docs/src/pom.md +++ b/docs/src/pom.md @@ -95,13 +95,13 @@ test('getting started should contain table of contents', async ({ page }) => { await playwrightDev.getStarted(); await expect(playwrightDev.tocList).toHaveText([ `How to install Playwright`, - `What's Installed`, + `What's installed`, `How to run the example test`, `How to open the HTML test report`, - `Write tests using web first assertions, page fixtures and locators`, - `Run single test, multiple tests, headed mode`, + `Write tests using web-first assertions, fixtures and locators`, + `Run single or multiple tests; headed mode`, `Generate tests with Codegen`, - `See a trace of your tests` + `View a trace of your tests`, ]); }); @@ -122,13 +122,13 @@ await playwrightDev.goto(); await playwrightDev.getStarted(); await expect(playwrightDev.tocList).toHaveText([ `How to install Playwright`, - `What's Installed`, + `What's installed`, `How to run the example test`, `How to open the HTML test report`, - `Write tests using web first assertions, page fixtures and locators`, - `Run single test, multiple tests, headed mode`, + `Write tests using web-first assertions, fixtures and locators`, + `Run single or multiple tests; headed mode`, `Generate tests with Codegen`, - `See a trace of your tests` + `View a trace of your tests`, ]); ```