From 9f1cf8fd36a2c62bbf6a137cbff1dab4888eca93 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Thu, 4 Jun 2026 12:10:38 -0700 Subject: [PATCH 01/10] feat(webkit): add classic W3C WebDriver backend for Safari MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a wd* backend (next to webview/) that drives Safari over the classic W3C WebDriver protocol exposed by safaridriver — both by launching safaridriver locally (webdriver+launch://) and by connecting to an existing endpoint (webdriver://), reached via webkit.connectOverCDP(). WebDriver pushes no events, so the backend synthesizes the frame, execution context and lifecycle events the core Page/Frame machinery expects. Since WebDriver has no persistent object handles, live handles are emulated with a page-side registry (window.__pwHandles), which lets the InjectedScript hit-target interceptor, element handles and waitForFunction-style polling work across the handle-less execute boundary. Verified end-to-end against real Safari: goto, evaluate, screenshot, fill/type/click, and locator queries. Also adds tests/webdriver/ which reuses the existing tests/page/ suite via PWPAGE_IMPL=webkit-webdriver (mirroring tests/webview/). Run with `npm run wdtest`. --- package.json | 1 + .../src/server/webkit/DEPS.list | 1 + .../src/server/webkit/webdriver/wdBrowser.ts | 288 +++++++++++++++++ .../server/webkit/webdriver/wdConnection.ts | 187 ++++++++++++ .../webkit/webdriver/wdExecutionContext.ts | 190 ++++++++++++ .../src/server/webkit/webdriver/wdInput.ts | 134 ++++++++ .../src/server/webkit/webdriver/wdPage.ts | 289 ++++++++++++++++++ .../src/server/webkit/webkit.ts | 7 + tests/page/pageTest.ts | 3 + tests/webdriver/playwright.config.ts | 65 ++++ tests/webdriver/webdriverTest.ts | 102 +++++++ 11 files changed, 1267 insertions(+) create mode 100644 packages/playwright-core/src/server/webkit/webdriver/wdBrowser.ts create mode 100644 packages/playwright-core/src/server/webkit/webdriver/wdConnection.ts create mode 100644 packages/playwright-core/src/server/webkit/webdriver/wdExecutionContext.ts create mode 100644 packages/playwright-core/src/server/webkit/webdriver/wdInput.ts create mode 100644 packages/playwright-core/src/server/webkit/webdriver/wdPage.ts create mode 100644 tests/webdriver/playwright.config.ts create mode 100644 tests/webdriver/webdriverTest.ts diff --git a/package.json b/package.json index 148f7a504f617..d26d7f39b1fb5 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "ftest": "playwright test --config=tests/library/playwright.config.ts --project=firefox-*", "wtest": "playwright test --config=tests/library/playwright.config.ts --project=webkit-*", "wvtest": "playwright test --config=tests/webview/playwright.config.ts", + "wdtest": "playwright test --config=tests/webdriver/playwright.config.ts", "atest": "playwright test --config=tests/android/playwright.config.ts", "etest": "playwright test --config=tests/electron/playwright.config.ts", "itest": "playwright test --config=tests/installation/playwright.config.ts", diff --git a/packages/playwright-core/src/server/webkit/DEPS.list b/packages/playwright-core/src/server/webkit/DEPS.list index 6935c13606896..a36b803083001 100644 --- a/packages/playwright-core/src/server/webkit/DEPS.list +++ b/packages/playwright-core/src/server/webkit/DEPS.list @@ -10,3 +10,4 @@ node_modules/ws [webkit.ts] ./webview/wvBrowser.ts +./webdriver/wdBrowser.ts diff --git a/packages/playwright-core/src/server/webkit/webdriver/wdBrowser.ts b/packages/playwright-core/src/server/webkit/webdriver/wdBrowser.ts new file mode 100644 index 0000000000000..c236b3b0ae371 --- /dev/null +++ b/packages/playwright-core/src/server/webkit/webdriver/wdBrowser.ts @@ -0,0 +1,288 @@ +/** + * 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 { ChildProcess, spawn } from 'child_process'; +import net from 'net'; +import os from 'os'; +import path from 'path'; + +import { RecentLogsCollector } from '@utils/debugLogger'; +import { removeFolders } from '@utils/fileUtils'; +import { Browser } from '../../browser'; +import { BrowserContext } from '../../browserContext'; +import { helper } from '../../helper'; +import * as network from '../../network'; +import { WDConnection, WDSession } from './wdConnection'; +import { WDPage } from './wdPage'; + +import type { BrowserOptions, BrowserProcess } from '../../browser'; +import type { SdkObject } from '../../instrumentation'; +import type { InitScript, Page } from '../../page'; +import type { Progress } from '../../progress'; +import type * as types from '../../types'; +import type * as channels from '@protocol/channels'; + +const kLaunchScheme = 'webdriver+launch://'; + +// Translates a `webdriver://host:port` endpoint into an `http://host:port` base. +function toHttpBase(endpointURL: string): string { + return endpointURL.replace(/^webdriver:\/\//, 'http://'); +} + +function findFreePort(): Promise { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.listen(0, () => { + const port = (server.address() as net.AddressInfo).port; + server.close(() => resolve(port)); + }); + server.on('error', reject); + }); +} + +async function launchSafariDriver(logs: RecentLogsCollector): Promise<{ baseURL: string, process: ChildProcess }> { + const port = await findFreePort(); + const proc = spawn('safaridriver', ['--port', String(port)], { stdio: ['ignore', 'pipe', 'pipe'] }); + proc.stdout?.on('data', d => logs.log(`[safaridriver] ${String(d).trim()}`)); + proc.stderr?.on('data', d => logs.log(`[safaridriver] ${String(d).trim()}`)); + proc.on('error', e => logs.log(`[safaridriver] failed to spawn: ${e.message}`)); + return { baseURL: `http://localhost:${port}`, process: proc }; +} + +async function waitForReady(baseURL: string, progress: Progress): Promise { + await progress.race((async () => { + for (;;) { + try { + const res = await fetch(`${baseURL}/status`); + if (res.ok) { + const json = await res.json() as { value?: { ready?: boolean } }; + if (json?.value?.ready !== false) + return; + } + } catch { + // safaridriver not accepting connections yet — retry. + } + await new Promise(f => setTimeout(f, 100)); + } + })()); +} + +export async function connectOverWebDriver(progress: Progress, parent: SdkObject, params: channels.BrowserTypeConnectOverCDPParams): Promise { + const endpointURL = params.endpointURL || ''; + const browserLogsCollector = new RecentLogsCollector(); + + const artifactsDir = params.artifactsDir ?? path.join(os.tmpdir(), 'playwright-artifacts-'); + const doCleanup = async () => { + await removeFolders([artifactsDir]); + }; + + const browser = await progress.race((async () => { + let baseURL: string; + let driverProcess: ChildProcess | undefined; + if (endpointURL.startsWith(kLaunchScheme)) { + const launched = await launchSafariDriver(browserLogsCollector); + baseURL = launched.baseURL; + driverProcess = launched.process; + } else { + baseURL = toHttpBase(endpointURL); + } + await waitForReady(baseURL, progress); + + const connection = new WDConnection(baseURL, helper.debugProtocolLogger(), browserLogsCollector); + const session = await WDSession.create(connection, { alwaysMatch: { browserName: 'safari' } }); + + const created = new WDBrowser(parent, connection, session, { + slowMo: params.slowMo, + name: 'webkit', + browserType: 'webkit', + browserProcess: { close: async () => {}, kill: async () => {} } as BrowserProcess, + protocolLogger: helper.debugProtocolLogger(), + browserLogsCollector, + artifactsDir, + downloadsPath: artifactsDir, + tracesDir: artifactsDir, + originalLaunchOptions: {}, + }); + const shutdown = async () => { + await session.delete().catch(() => {}); + connection.close(); + driverProcess?.kill('SIGTERM'); + await doCleanup(); + // Signal disconnection so Browser.close() (and remote clients) settle. + created._browserClosed(); + }; + created.options.browserProcess = { close: shutdown, kill: shutdown }; + await created._initialize(); + return created; + })()); + + if (!params.isLocal) + browser._isBrowserCollocatedWithServer = false; + browser.on(Browser.Events.Disconnected, doCleanup); + return browser; +} + +export class WDBrowser extends Browser { + readonly _context: WDBrowserContext; + readonly _connection: WDConnection; + readonly _session: WDSession; + _page!: WDPage; + private _didCloseFired = false; + + constructor(parent: SdkObject, connection: WDConnection, session: WDSession, options: BrowserOptions) { + super(parent, options); + this._connection = connection; + this._session = session; + this._context = new WDBrowserContext(this); + } + + async _initialize(): Promise { + await this._context.initialize(); + this._page = new WDPage(this._context, this._session); + await this._page._initialize(); + await this._page.waitForInitialized(); + } + + async doCreateNewContext(options: types.BrowserContextOptions): Promise { + throw new Error('Creating new contexts is not supported over WebDriver.'); + } + + contexts(): BrowserContext[] { + return [this._context]; + } + + version(): string { + return ''; + } + + userAgent(): string { + return ''; + } + + isConnected(): boolean { + return !this._connection.isClosed(); + } + + _browserClosed(): void { + if (this._didCloseFired) + return; + this._didCloseFired = true; + this.didClose(); + } +} + +export class WDBrowserContext extends BrowserContext { + declare readonly _browser: WDBrowser; + + constructor(browser: WDBrowser) { + super(browser, {}, ''); + } + + private _session(): WDSession { + return this._browser._session; + } + + override possiblyUninitializedPages(): Page[] { + return this._browser._page ? [this._browser._page._page] : []; + } + + override async doCreateNewPage(): Promise { + throw new Error('Creating new pages is not supported over WebDriver.'); + } + + async doGetCookies(urls: string[]): Promise { + const raw = await this._session().getCookies().catch(() => [] as any[]); + const cookies: channels.NetworkCookie[] = raw.map((c: any) => ({ + name: c.name, + value: c.value, + domain: c.domain || '', + path: c.path || '/', + expires: typeof c.expiry === 'number' ? c.expiry : -1, + httpOnly: !!c.httpOnly, + secure: !!c.secure, + sameSite: (c.sameSite as channels.NetworkCookie['sameSite']) || 'None', + })); + return network.filterCookies(cookies, urls); + } + + async addCookies(cookies: channels.SetNetworkCookie[]) { + for (const c of network.rewriteCookies(cookies)) { + // WebDriver only sets cookies for the active document's domain — best effort. + await this._session().addCookie({ + name: c.name, + value: c.value, + path: c.path, + domain: c.domain, + secure: c.secure, + httpOnly: c.httpOnly, + sameSite: c.sameSite, + expiry: c.expires && c.expires !== -1 ? Math.ceil(c.expires) : undefined, + }).catch(() => {}); + } + } + + async doClearCookies() { + await this._session().deleteAllCookies(); + } + + async doUpdateExtraHTTPHeaders(): Promise {} + async doUpdateOffline(): Promise {} + async doUpdateRequestInterception(): Promise {} + async doUpdateDefaultViewport(): Promise {} + async doUpdateDefaultEmulatedMedia(): Promise {} + override async doExposePlaywrightBinding(): Promise {} + override async onClosePersistent(): Promise {} + + async setGeolocation(geolocation?: types.Geolocation): Promise { + throw new Error('Method not implemented.'); + } + + async doGrantPermissions(origin: string, permissions: string[]) { + throw new Error('Method not implemented.'); + } + + async doClearPermissions() { + throw new Error('Method not implemented.'); + } + + async doAddInitScript(initScript: InitScript) { + throw new Error('Method not implemented.'); + } + + async doRemoveInitScripts(initScripts: InitScript[]) { + throw new Error('Method not implemented.'); + } + + override async clearCache(): Promise { + throw new Error('Method not implemented.'); + } + + override async doClose(reason: string | undefined): Promise { + throw new Error('Method not implemented.'); + } + + override async cancelDownload(uuid: string) { + throw new Error('Method not implemented.'); + } + + override async setUserAgent(userAgent: string | undefined): Promise { + throw new Error('Method not implemented.'); + } + + protected override async doSetHTTPCredentials(httpCredentials?: types.Credentials): Promise { + throw new Error('Method not implemented.'); + } +} diff --git a/packages/playwright-core/src/server/webkit/webdriver/wdConnection.ts b/packages/playwright-core/src/server/webkit/webdriver/wdConnection.ts new file mode 100644 index 0000000000000..7945016949ae2 --- /dev/null +++ b/packages/playwright-core/src/server/webkit/webdriver/wdConnection.ts @@ -0,0 +1,187 @@ +/** + * 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 { ProtocolError } from '../../protocolError'; + +import type { RecentLogsCollector } from '@utils/debugLogger'; +import type { ProtocolLogger } from '../../types'; + +export type WDCapabilities = Record; + +type WDResponse = { value: any }; + +/** + * Thin classic W3C WebDriver HTTP client. Unlike the WebKit inspector protocol + * (see ../webview/wvConnection.ts) WebDriver is a synchronous request/response + * protocol with no pushed events, so there is no event dispatch loop here — just + * a `command()` that issues an HTTP request and unwraps the `{ value }` envelope. + */ +export class WDConnection { + readonly baseURL: string; + private readonly _protocolLogger: ProtocolLogger; + private readonly _browserLogsCollector: RecentLogsCollector; + private _closed = false; + + constructor(baseURL: string, protocolLogger: ProtocolLogger, browserLogsCollector: RecentLogsCollector) { + // Normalize a trailing slash so callers can build `${baseURL}/session/...`. + this.baseURL = baseURL.replace(/\/$/, ''); + this._protocolLogger = protocolLogger; + this._browserLogsCollector = browserLogsCollector; + } + + async command(httpMethod: 'GET' | 'POST' | 'DELETE', path: string, body?: any): Promise { + if (this._closed) + throw this._error('closed', undefined, 'WebDriver connection is closed'); + const url = `${this.baseURL}${path}`; + this._protocolLogger('send', { method: `${httpMethod} ${path}`, params: body } as any); + let res: Response; + try { + res = await fetch(url, { + method: httpMethod, + headers: body !== undefined ? { 'Content-Type': 'application/json; charset=utf-8' } : undefined, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + } catch (e) { + throw this._error('closed', `${httpMethod} ${path}`, `WebDriver request failed: ${(e as Error).message}`); + } + const text = await res.text(); + let json: WDResponse | undefined; + try { + json = text ? JSON.parse(text) as WDResponse : undefined; + } catch { + throw this._error('error', `${httpMethod} ${path}`, `Non-JSON WebDriver response (${res.status}): ${text.slice(0, 200)}`); + } + this._protocolLogger('receive', { result: json?.value } as any); + // A WebDriver error is signalled both by a non-2xx status and a `value.error` + // string (https://www.w3.org/TR/webdriver/#errors). + const value = json?.value; + if (!res.ok || (value && typeof value === 'object' && typeof value.error === 'string')) { + const error = (value && value.error) || `HTTP ${res.status}`; + const message = (value && value.message) || res.statusText; + this._browserLogsCollector.log(`webdriver error ${error}: ${message}`); + throw this._error('error', `${httpMethod} ${path}`, `${error}: ${message}`); + } + return value; + } + + private _error(type: 'error' | 'closed', method: string | undefined, message: string): ProtocolError { + const error = new ProtocolError(type, method); + error.setMessage(message); + return error; + } + + isClosed(): boolean { + return this._closed; + } + + close() { + this._closed = true; + } +} + +/** + * A WebDriver session — wraps a sessionId and exposes the per-session endpoints + * we use. Construct via `WDConnection.createSession`. + */ +export class WDSession { + readonly connection: WDConnection; + readonly sessionId: string; + + constructor(connection: WDConnection, sessionId: string) { + this.connection = connection; + this.sessionId = sessionId; + } + + static async create(connection: WDConnection, capabilities: WDCapabilities): Promise { + const value = await connection.command('POST', '/session', { capabilities }); + const sessionId = value.sessionId; + if (!sessionId) + throw new Error('WebDriver did not return a sessionId'); + return new WDSession(connection, sessionId); + } + + send(httpMethod: 'GET' | 'POST' | 'DELETE', command: string, body?: any): Promise { + return this.connection.command(httpMethod, `/session/${this.sessionId}/${command}`, body); + } + + // Runs a script synchronously in the top-level browsing context. + executeSync(script: string, args: any[] = []): Promise { + return this.send('POST', 'execute/sync', { script, args }); + } + + // Runs a script that resolves a callback (the last argument) — lets us await + // promises that `execute/sync` would otherwise drop on the floor. + executeAsync(script: string, args: any[] = []): Promise { + return this.send('POST', 'execute/async', { script, args }); + } + + navigate(url: string): Promise { + return this.send('POST', 'url', { url }); + } + + currentUrl(): Promise { + return this.send('GET', 'url'); + } + + title(): Promise { + return this.send('GET', 'title'); + } + + windowHandle(): Promise { + return this.send('GET', 'window'); + } + + reload(): Promise { + return this.send('POST', 'refresh'); + } + + back(): Promise { + return this.send('POST', 'back'); + } + + forward(): Promise { + return this.send('POST', 'forward'); + } + + // Returns a base64-encoded PNG of the current viewport. + screenshot(): Promise { + return this.send('GET', 'screenshot'); + } + + getCookies(): Promise { + return this.send('GET', 'cookie'); + } + + addCookie(cookie: any): Promise { + return this.send('POST', 'cookie', { cookie }); + } + + deleteAllCookies(): Promise { + return this.send('DELETE', 'cookie'); + } + + performActions(actions: any[]): Promise { + return this.send('POST', 'actions', { actions }); + } + + releaseActions(): Promise { + return this.send('DELETE', 'actions'); + } + + async delete(): Promise { + await this.connection.command('DELETE', `/session/${this.sessionId}`).catch(() => {}); + } +} diff --git a/packages/playwright-core/src/server/webkit/webdriver/wdExecutionContext.ts b/packages/playwright-core/src/server/webkit/webdriver/wdExecutionContext.ts new file mode 100644 index 0000000000000..e9e8cc9518e7e --- /dev/null +++ b/packages/playwright-core/src/server/webkit/webdriver/wdExecutionContext.ts @@ -0,0 +1,190 @@ +/** + * 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 { parseEvaluationResultValue } from '@isomorphic/utilityScriptSerializers'; +import * as js from '../../javascript'; +import * as dom from '../../dom'; + +import type { WDSession } from './wdConnection'; + +let lastHandleId = 0; + +// Metadata we attach to a JSHandle so it can be re-materialized inside a +// WebDriver `execute` call. A handle is one of: +// - `source`: a self-contained IIFE we inline to reconstruct the object +// (UtilityScript, InjectedScript) — stateless, rebuilt per call. +// - `registryId`: a key into the page-side `window.__pwHandles` map, which is +// how we emulate live remote handles over a protocol that has +// none. Valid until the document navigates. +type WDHandleMeta = { + source?: string; + registryId?: string; +}; + +// Lazily creates the page-side live-handle registry. Idempotent; included at the +// top of every script so `window.__pwHandles.get(...)` is always available. +const kRegistryInit = `window.__pwHandles = window.__pwHandles || new Map(); window.__pwHandleSeq = window.__pwHandleSeq || 0;`; + +/** + * ExecutionContextDelegate over classic W3C WebDriver `execute/async`. + * + * WebDriver has no persistent object handles, so we emulate them with a page-side + * registry (`window.__pwHandles`): when an evaluate returns a non-serializable + * value (a DOM node, the InjectedScript hit-target interceptor, …) we stash it in + * the registry and return its key. Passing that handle back inlines a + * `window.__pwHandles.get(key)` lookup, so the live object is recovered in-page. + * UtilityScript/InjectedScript are instead inlined from source each call. + */ +export class WDExecutionContext implements js.ExecutionContextDelegate { + private readonly _session: WDSession; + private readonly _handleMeta = new Map(); + + constructor(session: WDSession) { + this._session = session; + } + + async rawEvaluateJSON(expression: string): Promise { + const result = await this._session.executeAsync(wrapAsync(`return (${expression});`)); + return unwrap(result); + } + + async rawEvaluateHandle(context: js.ExecutionContext, expression: string): Promise { + // The only callers are `_utilityScript()` / `injectedScript()`, which pass a + // self-contained IIFE source. Stash the source on a placeholder handle and + // inline it on every evaluate that references it. + const objectId = `wd-source-${++lastHandleId}`; + this._handleMeta.set(objectId, { source: expression }); + return new js.JSHandle(context, 'object', undefined, objectId); + } + + async evaluateWithArguments(expression: string, returnByValue: boolean, utilityScript: js.JSHandle, values: any[], handles: js.JSHandle[]): Promise { + // `expression` is `(utilityScript, ...args) => utilityScript.evaluate(...args)`, + // `values` is `[isFunction, returnByValue, userExpr, argCount, ...serializedArgs]`, + // and `handles` are JSHandle arguments referenced from `serializedArgs`. + const utilityExpr = this._inlineHandle(utilityScript); + const handleExprs = handles.map(handle => this._inlineHandle(handle)); + const call = `(${expression})(${utilityExpr}, ...__pwValues, ...[${handleExprs.join(', ')}])`; + + let body: string; + if (returnByValue) { + // UtilityScript already returns a JSON-serializable structure. + body = `const __pwValues = __pwArgs[0]; return ${call};`; + } else { + // Recover a live handle. The InjectedScript polling protocol returns + // `{ result: , abort }` and expects the resolved `.result` to be + // read later; resolve it here (in-page, where the Promise is live) before + // storing, so it survives. Primitives are returned inline. + body = ` + const __pwValues = __pwArgs[0]; + let __pwR = await ${call}; + if (__pwR && typeof __pwR === 'object' && typeof __pwR.abort === 'function' && __pwR.result && typeof __pwR.result.then === 'function') + __pwR = { result: await __pwR.result, abort: __pwR.abort }; + if (__pwR === null || (typeof __pwR !== 'object' && typeof __pwR !== 'function')) + return { __pwPrimitive: true, value: __pwR }; + const __pwId = 'h' + (++window.__pwHandleSeq); + window.__pwHandles.set(__pwId, __pwR); + return { __pwHandleId: __pwId, isElement: (typeof Node !== 'undefined' && __pwR instanceof Node) }; + `; + } + + const result = await this._session.executeAsync(wrapAsync(body), [values]); + const value = unwrap(result); + if (returnByValue) + return parseEvaluationResultValue(value); + return this._createHandleFromResult(utilityScript._context, value); + } + + async getProperties(object: js.JSHandle): Promise> { + const meta = object._objectId ? this._handleMeta.get(object._objectId) : undefined; + if (!meta?.registryId) + return new Map(); + const expression = `() => { + const obj = window.__pwHandles.get(${JSON.stringify(meta.registryId)}); + const out = []; + if (obj) { + for (const name of Object.keys(obj)) { + const id = 'h' + (++window.__pwHandleSeq); + window.__pwHandles.set(id, obj[name]); + out.push({ name, id }); + } + } + return out; + }`; + const entries = await this._session.executeAsync(wrapAsync(`${kRegistryInit} return (${expression})();`)); + const list = unwrap(entries) as { name: string, id: string }[]; + const result = new Map(); + for (const { name, id } of list) { + const objectId = `wd-obj-${id}`; + this._handleMeta.set(objectId, { registryId: id }); + result.set(name, new js.JSHandle(object._context, 'object', undefined, objectId)); + } + return result; + } + + async releaseHandle(handle: js.JSHandle): Promise { + if (!handle._objectId) + return; + const meta = this._handleMeta.get(handle._objectId); + this._handleMeta.delete(handle._objectId); + if (meta?.registryId) + await this._session.executeAsync(wrapAsync(`window.__pwHandles && window.__pwHandles.delete(${JSON.stringify(meta.registryId)}); return undefined;`)).catch(() => {}); + } + + shouldPrependErrorPrefix(): boolean { + return false; + } + + // Builds the JS expression that recovers a handle inside a WebDriver script. + private _inlineHandle(handle: js.JSHandle): string { + const meta = handle._objectId ? this._handleMeta.get(handle._objectId) : undefined; + if (meta?.source) + return `(${meta.source.trim().replace(/;+\s*$/, '')})`; + if (meta?.registryId) + return `window.__pwHandles.get(${JSON.stringify(meta.registryId)})`; + throw new js.JavaScriptErrorInEvaluate('Cannot pass an unknown JSHandle to a WebDriver evaluate.'); + } + + private _createHandleFromResult(context: js.ExecutionContext, descriptor: any): js.JSHandle { + if (descriptor && descriptor.__pwPrimitive) + return new js.JSHandle(context, typeof descriptor.value, undefined, undefined, descriptor.value); + const registryId = descriptor?.__pwHandleId as string | undefined; + const objectId = `wd-obj-${registryId}`; + this._handleMeta.set(objectId, { registryId }); + if (descriptor?.isElement && context instanceof dom.FrameExecutionContext) + return new dom.ElementHandle(context, objectId); + return new js.JSHandle(context, 'object', undefined, objectId); + } +} + +// Wraps a body into an `execute/async` script: the last argument is the +// completion callback. Lets the page function return a promise and surfaces +// thrown errors as `{ __pwError }` rather than hanging. +function wrapAsync(body: string): string { + return ` + const __pwArgs = arguments; + const __pwDone = __pwArgs[__pwArgs.length - 1]; + ${kRegistryInit} + (async () => { + ${body} + })().then(value => __pwDone({ value }), error => __pwDone({ __pwError: (error && error.stack) || String(error) })); + `; +} + +function unwrap(result: any): any { + if (result && typeof result === 'object' && '__pwError' in result) + throw new js.JavaScriptErrorInEvaluate(String(result.__pwError)); + return result ? result.value : undefined; +} diff --git a/packages/playwright-core/src/server/webkit/webdriver/wdInput.ts b/packages/playwright-core/src/server/webkit/webdriver/wdInput.ts new file mode 100644 index 0000000000000..4186366a21e9a --- /dev/null +++ b/packages/playwright-core/src/server/webkit/webdriver/wdInput.ts @@ -0,0 +1,134 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as input from '../../input'; + +import type * as types from '../../types'; +import type { WDSession } from './wdConnection'; +import type { Progress } from '../../progress'; + +// W3C WebDriver normalized key values for non-printable keys (U+E000..U+E05D). +// https://www.w3.org/TR/webdriver/#keyboard-actions +const kW3CKeys: Record = { + 'Cancel': '\uE001', 'Help': '\uE002', 'Backspace': '\uE003', 'Tab': '\uE004', 'Clear': '\uE005', + 'Enter': '\uE007', 'Shift': '\uE008', 'Control': '\uE009', 'Alt': '\uE00A', 'Pause': '\uE00B', 'Escape': '\uE00C', + 'PageUp': '\uE00E', 'PageDown': '\uE00F', 'End': '\uE010', 'Home': '\uE011', + 'ArrowLeft': '\uE012', 'ArrowUp': '\uE013', 'ArrowRight': '\uE014', 'ArrowDown': '\uE015', + 'Insert': '\uE016', 'Delete': '\uE017', 'Meta': '\uE03D', + 'F1': '\uE031', 'F2': '\uE032', 'F3': '\uE033', 'F4': '\uE034', 'F5': '\uE035', 'F6': '\uE036', + 'F7': '\uE037', 'F8': '\uE038', 'F9': '\uE039', 'F10': '\uE03A', 'F11': '\uE03B', 'F12': '\uE03C', +}; + +function w3cKeyValue(description: input.KeyDescription): string { + if (kW3CKeys[description.key]) + return kW3CKeys[description.key]; + // Printable keys: the single-character key value carries the right glyph. + if (description.key.length === 1) + return description.key; + return description.text || description.key; +} + +function buttonNumber(button: types.MouseButton): number { + if (button === 'middle') + return 1; + if (button === 'right') + return 2; + return 0; +} + +/** + * Input over the W3C WebDriver Actions API (`POST /session/{id}/actions`). + * + * WebDriver keeps per-session input state between `performActions` calls, so the + * separate move/down/up delegate calls that Playwright issues accumulate + * correctly. Keyboard modifiers are already pressed as real key actions by + * Playwright's input layer, so the mouse handlers do not re-apply them. + */ +class RawInput { + protected _session: WDSession | undefined; + + setSession(session: WDSession) { + this._session = session; + } + + protected async _perform(progress: Progress, actions: any[]): Promise { + if (!this._session) + throw new Error('Page is not initialized'); + await progress.race(this._session.performActions(actions)); + } +} + +export class RawKeyboardImpl extends RawInput implements input.RawKeyboard { + async keydown(progress: Progress, modifiers: Set, keyName: string, description: input.KeyDescription, autoRepeat: boolean): Promise { + await this._key(progress, 'keyDown', w3cKeyValue(description)); + } + + async keyup(progress: Progress, modifiers: Set, keyName: string, description: input.KeyDescription): Promise { + await this._key(progress, 'keyUp', w3cKeyValue(description)); + } + + async sendText(progress: Progress, text: string): Promise { + const actions = []; + for (const char of text) + actions.push({ type: 'keyDown', value: char }, { type: 'keyUp', value: char }); + await this._perform(progress, [{ type: 'key', id: 'keyboard', actions }]); + } + + private async _key(progress: Progress, type: 'keyDown' | 'keyUp', value: string): Promise { + await this._perform(progress, [{ type: 'key', id: 'keyboard', actions: [{ type, value }] }]); + } +} + +export class RawMouseImpl extends RawInput implements input.RawMouse { + async move(progress: Progress, x: number, y: number, button: types.MouseButton | 'none', buttons: Set, modifiers: Set, forClick: boolean): Promise { + await this._pointer(progress, [{ type: 'pointerMove', duration: 0, origin: 'viewport', x: Math.round(x), y: Math.round(y) }]); + } + + async down(progress: Progress, x: number, y: number, button: types.MouseButton, buttons: Set, modifiers: Set, clickCount: number): Promise { + await this._pointer(progress, [{ type: 'pointerDown', button: buttonNumber(button) }]); + } + + async up(progress: Progress, x: number, y: number, button: types.MouseButton, buttons: Set, modifiers: Set, clickCount: number): Promise { + await this._pointer(progress, [{ type: 'pointerUp', button: buttonNumber(button) }]); + } + + async wheel(progress: Progress, x: number, y: number, buttons: Set, modifiers: Set, deltaX: number, deltaY: number): Promise { + await this._perform(progress, [{ + type: 'wheel', + id: 'wheel', + actions: [{ type: 'scroll', x: Math.round(x), y: Math.round(y), deltaX: Math.round(deltaX), deltaY: Math.round(deltaY), origin: 'viewport' }], + }]); + } + + private async _pointer(progress: Progress, actions: any[]): Promise { + await this._perform(progress, [{ type: 'pointer', id: 'mouse', parameters: { pointerType: 'mouse' }, actions }]); + } +} + +export class RawTouchscreenImpl extends RawInput implements input.RawTouchscreen { + async tap(progress: Progress, x: number, y: number, modifiers: Set): Promise { + await this._perform(progress, [{ + type: 'pointer', + id: 'finger', + parameters: { pointerType: 'touch' }, + actions: [ + { type: 'pointerMove', duration: 0, origin: 'viewport', x: Math.round(x), y: Math.round(y) }, + { type: 'pointerDown', button: 0 }, + { type: 'pointerUp', button: 0 }, + ], + }]); + } +} diff --git a/packages/playwright-core/src/server/webkit/webdriver/wdPage.ts b/packages/playwright-core/src/server/webkit/webdriver/wdPage.ts new file mode 100644 index 0000000000000..edad10e5c2f73 --- /dev/null +++ b/packages/playwright-core/src/server/webkit/webdriver/wdPage.ts @@ -0,0 +1,289 @@ +/** + * 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 { PNG } from 'pngjs'; +import jpegjs from 'jpeg-js'; +import { ManualPromise } from '@isomorphic/manualPromise'; +import { createGuid } from '@utils/crypto'; +import * as dom from '../../dom'; +import { Page } from '../../page'; +import { WDExecutionContext } from './wdExecutionContext'; +import { RawKeyboardImpl, RawMouseImpl, RawTouchscreenImpl } from './wdInput'; + +import type { WDBrowserContext } from './wdBrowser'; +import type { WDSession } from './wdConnection'; +import type * as frames from '../../frames'; +import type { InitScript, PageDelegate } from '../../page'; +import type { Progress } from '../../progress'; +import type * as types from '../../types'; +import type { PagePdfParams } from '@protocol/channels'; + +/** + * PageDelegate backed by a classic W3C WebDriver session. + * + * WebDriver pushes no events, so this delegate *synthesizes* the frame, + * execution-context and lifecycle events the core Page/Frame machinery expects: + * there is a single (main) frame, a single execution context that is torn down + * and recreated on every navigation, and lifecycle events fired right after a + * blocking WebDriver navigation returns. See ../webview/wvPage.ts for the + * event-driven counterpart. + */ +export class WDPage implements PageDelegate { + readonly rawMouse: RawMouseImpl; + readonly rawKeyboard: RawKeyboardImpl; + readonly rawTouchscreen: RawTouchscreenImpl; + readonly _page: Page; + readonly _browserContext: WDBrowserContext; + private readonly _session: WDSession; + private _mainFrameId: string = 'main'; + private _context: dom.FrameExecutionContext | undefined; + private readonly _initializedPromise = new ManualPromise(); + + pdf?: ((options: PagePdfParams) => Promise) | undefined; + coverage?: (() => any) | undefined; + cspErrorsAsynchronousForInlineScripts?: boolean | undefined; + + constructor(browserContext: WDBrowserContext, session: WDSession) { + this._browserContext = browserContext; + this._session = session; + this.rawKeyboard = new RawKeyboardImpl(); + this.rawMouse = new RawMouseImpl(); + this.rawTouchscreen = new RawTouchscreenImpl(); + this.rawKeyboard.setSession(session); + this.rawMouse.setSession(session); + this.rawTouchscreen.setSession(session); + this._page = new Page(this, browserContext); + } + + waitForInitialized(): Promise { + return this._initializedPromise; + } + + async _initialize(): Promise { + let pageOrError: Page | Error; + try { + // The top-level window handle is a stable id for the main frame. + this._mainFrameId = await this._session.windowHandle().catch(() => 'main'); + this._page.frameManager.frameAttached(this._mainFrameId, null); + this._createContext(); + const url = await this._session.currentUrl().catch(() => 'about:blank'); + this._page.frameManager.frameCommittedNewDocumentNavigation(this._mainFrameId, url, '', createGuid(), true); + this._page.frameManager.frameLifecycleEvent(this._mainFrameId, 'domcontentloaded'); + this._page.frameManager.frameLifecycleEvent(this._mainFrameId, 'load'); + pageOrError = this._page; + } catch (e) { + pageOrError = e as Error; + } + await this._page.reportAsNew(undefined, pageOrError instanceof Page ? undefined : pageOrError); + this._initializedPromise.resolve(); + } + + private _mainFrame(): frames.Frame { + return this._page.frameManager.frame(this._mainFrameId)!; + } + + private _createContext(): void { + const frame = this._mainFrame(); + const context = new dom.FrameExecutionContext(new WDExecutionContext(this._session), frame, 'main'); + this._context = context; + frame.contextCreated('main', context); + } + + // Synthesizes the commit + context + lifecycle events for a navigation that + // the (blocking) WebDriver command has already completed. Returns the freshly + // generated document id so navigateFrame can satisfy gotoImpl's predicate. + private async _didNavigate(fallbackUrl: string): Promise { + const frame = this._mainFrame(); + if (this._context) { + frame.contextDestroyed(this._context); + this._context = undefined; + } + const url = await this._session.currentUrl().catch(() => fallbackUrl); + const documentId = createGuid(); + this._page.frameManager.frameCommittedNewDocumentNavigation(this._mainFrameId, url, '', documentId, false); + this._createContext(); + this._page.frameManager.frameLifecycleEvent(this._mainFrameId, 'domcontentloaded'); + this._page.frameManager.frameLifecycleEvent(this._mainFrameId, 'load'); + return { newDocumentId: documentId }; + } + + async navigateFrame(frame: frames.Frame, url: string, referrer: string | undefined): Promise { + if (frame.parentFrame()) + throw new Error('Only main-frame navigation is supported over WebDriver.'); + await this._session.navigate(url); + return await this._didNavigate(url); + } + + async reload(): Promise { + await this._session.reload(); + await this._didNavigate(this._mainFrame().url()); + } + + async goBack(): Promise { + await this._session.back(); + await this._didNavigate(this._mainFrame().url()); + return true; + } + + async goForward(): Promise { + await this._session.forward(); + await this._didNavigate(this._mainFrame().url()); + return true; + } + + async takeScreenshot(progress: Progress, format: string, documentRect: types.Rect | undefined, viewportRect: types.Rect | undefined, quality: number | undefined, fitsViewport: boolean, scale: 'css' | 'device'): Promise { + // WebDriver only exposes a viewport screenshot. + const base64 = await progress.race(this._session.screenshot()); + let buffer: Buffer = Buffer.from(base64, 'base64'); + if (format === 'jpeg') + buffer = jpegjs.encode(PNG.sync.read(buffer), quality).data; + return buffer; + } + + async getContentQuads(handle: dom.ElementHandle): Promise { + const result = await handle.evaluateInUtility(([, node]) => { + let element: Element | null = node as Element; + while (element && element.nodeType !== 1 /* Node.ELEMENT_NODE */) + element = element.parentNode as Element | null; + if (!element) + return null; + const rects = element.getClientRects(); + if (!rects.length) + return null; + return Array.from(rects, r => [ + { x: r.left, y: r.top }, + { x: r.right, y: r.top }, + { x: r.right, y: r.bottom }, + { x: r.left, y: r.bottom }, + ]); + }, {}); + if (!result || typeof result === 'string') + return null; + return result as types.Quad[]; + } + + async getBoundingBox(handle: dom.ElementHandle): Promise { + const quads = await this.getContentQuads(handle); + if (!quads || quads === 'error:notconnected' || !quads.length) + return null; + let minX = Infinity; + let maxX = -Infinity; + let minY = Infinity; + let maxY = -Infinity; + for (const quad of quads) { + for (const point of quad) { + minX = Math.min(minX, point.x); + maxX = Math.max(maxX, point.x); + minY = Math.min(minY, point.y); + maxY = Math.max(maxY, point.y); + } + } + return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }; + } + + async scrollRectIntoViewIfNeeded(handle: dom.ElementHandle, rect?: types.Rect): Promise<'error:notvisible' | 'error:notconnected' | 'done'> { + const result = await handle.evaluateInUtility(([, node, rect]) => { + if (!node.isConnected) + return 'error:notconnected'; + if (!(node instanceof Element)) + return 'error:notvisible'; + const box = node.getBoundingClientRect(); + if (!box.width && !box.height && !node.getClientRects().length) + return 'error:notvisible'; + const innerW = document.documentElement.clientWidth; + const innerH = document.documentElement.clientHeight; + const target = rect + ? { left: box.left + rect.x, top: box.top + rect.y, right: box.left + rect.x + rect.width, bottom: box.top + rect.y + rect.height } + : { left: box.left, top: box.top, right: box.right, bottom: box.bottom }; + const fullyVisible = target.left >= 0 && target.top >= 0 && target.right <= innerW && target.bottom <= innerH; + if (!fullyVisible) + node.scrollIntoView({ block: 'center', inline: 'center' }); + return 'done'; + }, rect ?? null); + if (result === 'error:notvisible' || result === 'error:notconnected' || result === 'done') + return result; + return 'error:notconnected'; + } + + async closePage(runBeforeUnload: boolean): Promise { + await this._session.delete(); + this.didClose(); + } + + didClose() { + this._session.connection.close(); + this._page._didClose(); + } + + // ---- No-op / best-effort PageDelegate surface for the proof-of-concept. ---- + + async setBackgroundColor(color?: { r: number; g: number; b: number; a: number; }): Promise {} + async bringToFront(): Promise {} + async updateExtraHTTPHeaders(): Promise {} + async updateEmulatedViewportSize(): Promise {} + async updateEmulateMedia(): Promise {} + async updateRequestInterception(): Promise {} + async updateFileChooserInterception(): Promise {} + async addInitScript(initScript: InitScript): Promise {} + async removeInitScripts(): Promise {} + async requestGC(): Promise {} + async inputActionEpilogue(): Promise {} + async resetForReuse(progress: Progress): Promise {} + async setDockTile(image: Buffer): Promise {} + async exposePlaywrightBinding(): Promise {} + + noUtilityWorld(): boolean { + return true; + } + + rafCountForStablePosition(): number { + return 1; + } + + shouldToggleStyleSheetToSyncAnimations(): boolean { + return false; + } + + // ---- Unsupported over classic WebDriver. ---- + + startScreencast(): void { + throw new Error('Screencast is not supported over WebDriver.'); + } + + stopScreencast(): void { + throw new Error('Screencast is not supported over WebDriver.'); + } + + async getContentFrame(handle: dom.ElementHandle): Promise { + throw new Error('Frames are not supported over WebDriver.'); + } + + async getOwnerFrame(handle: dom.ElementHandle): Promise { + throw new Error('Frames are not supported over WebDriver.'); + } + + async getFrameElement(frame: frames.Frame): Promise { + throw new Error('Frames are not supported over WebDriver.'); + } + + async setInputFilePaths(progress: Progress, handle: dom.ElementHandle, files: string[]): Promise { + throw new Error('Setting input files is not supported over WebDriver.'); + } + + async adoptElementHandle(handle: dom.ElementHandle, to: dom.FrameExecutionContext): Promise> { + throw new Error('Adopting element handles is not supported over WebDriver.'); + } +} diff --git a/packages/playwright-core/src/server/webkit/webkit.ts b/packages/playwright-core/src/server/webkit/webkit.ts index da412abdc192b..7dadf5ac6763c 100644 --- a/packages/playwright-core/src/server/webkit/webkit.ts +++ b/packages/playwright-core/src/server/webkit/webkit.ts @@ -24,6 +24,7 @@ import { Browser } from '../browser'; import { BrowserType, kNoXServerRunningError } from '../browserType'; import { WKBrowser } from './wkBrowser'; import { connectOverRDP } from './webview/wvBrowser'; +import { connectOverWebDriver } from './webdriver/wdBrowser'; import type { BrowserOptions } from '../browser'; import type { SdkObject } from '../instrumentation'; @@ -42,6 +43,12 @@ export class WebKit extends BrowserType { } override async connectOverCDP(progress: Progress, params: channels.BrowserTypeConnectOverCDPParams): Promise { + // `webdriver://host:port` / `webdriver+launch://safari` route to the classic + // W3C WebDriver backend (e.g. safaridriver); everything else is the WebKit + // inspector-protocol (webview) backend. + const endpointURL = params.endpointURL || ''; + if (endpointURL.startsWith('webdriver://') || endpointURL.startsWith('webdriver+launch://')) + return connectOverWebDriver(progress, this, params); return connectOverRDP(progress, this, params); } diff --git a/tests/page/pageTest.ts b/tests/page/pageTest.ts index 85c532a9a98b4..f69173681285d 100644 --- a/tests/page/pageTest.ts +++ b/tests/page/pageTest.ts @@ -21,6 +21,7 @@ import { androidTest } from '../android/androidTest'; import { browserTest } from '../config/browserTest'; import { electronTest } from '../electron/electronTest'; import { webviewTest } from '../webview/webviewTest'; +import { webdriverTest } from '../webdriver/webdriverTest'; import type { PageTestFixtures, PageWorkerFixtures } from './pageTestApi'; import type { ServerFixtures, ServerWorkerOptions } from '../config/serverFixtures'; import { expect as baseExpect } from '@playwright/test'; @@ -34,6 +35,8 @@ if (process.env.PWPAGE_IMPL === 'electron') impl = electronTest; if (process.env.PWPAGE_IMPL === 'webkit-webview') impl = webviewTest; +if (process.env.PWPAGE_IMPL === 'webkit-webdriver') + impl = webdriverTest; export const test = impl; diff --git a/tests/webdriver/playwright.config.ts b/tests/webdriver/playwright.config.ts new file mode 100644 index 0000000000000..a8c5bb05b973f --- /dev/null +++ b/tests/webdriver/playwright.config.ts @@ -0,0 +1,65 @@ +/** + * 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 { config as loadEnv } from 'dotenv'; +loadEnv({ path: path.join(__dirname, '..', '..', '.env') }); +process.env.PWTEST_UNDER_TEST = '1'; + +import type { Config, PlaywrightTestOptions, PlaywrightWorkerOptions } from '@playwright/test'; +import * as path from 'path'; +import type { ServerWorkerOptions } from '../config/serverFixtures'; + +process.env.PWPAGE_IMPL = 'webkit-webdriver'; + +const outputDir = path.join(__dirname, '..', '..', 'test-results'); +const testDir = path.join(__dirname, '..'); +const config: Config = { + testDir, + outputDir, + expect: { + timeout: 10000, + }, + timeout: 30000, + globalTimeout: 7200000, + workers: 1, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 0, + reporter: process.env.CI ? [ + ['list', { printFailuresInline: true }], + ['json', { outputFile: path.join(outputDir, 'report.json') }], + ] : 'line', + projects: [], +}; + +const metadata = { + platform: 'WebDriver', + headless: 'headless', + browserName: 'webkit', + mode: 'default', + video: false, +}; + +config.projects!.push({ + name: 'webkit-webdriver-page', + use: { + browserName: 'webkit', + }, + snapshotPathTemplate: '{testDir}/{testFileDir}/{testFileName}-snapshots/{arg}-webkit-webdriver{ext}', + testDir: path.join(testDir, 'page'), + metadata, +}); + +export default config; diff --git a/tests/webdriver/webdriverTest.ts b/tests/webdriver/webdriverTest.ts new file mode 100644 index 0000000000000..339547bf334e3 --- /dev/null +++ b/tests/webdriver/webdriverTest.ts @@ -0,0 +1,102 @@ +/** + * 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 { type ChildProcess, spawn, spawnSync } from 'child_process'; +import net from 'net'; + +import { baseTest } from '../config/baseTest'; + +import type { PageTestFixtures, PageWorkerFixtures } from '../page/pageTestApi'; +export { expect } from '@playwright/test'; + +type WebDriverWorkerFixtures = PageWorkerFixtures & { + webdriverEndpoint: string; +}; + +function killStraySafariDrivers(): void { + // A crashed run can leave safaridriver alive with Safari still "paired", + // which makes the next session creation fail. Clear it before launching. + spawnSync('pkill', ['-9', '-f', 'safaridriver']); +} + +function findFreePort(): Promise { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.listen(0, () => { + const port = (server.address() as net.AddressInfo).port; + server.close(() => resolve(port)); + }); + server.on('error', reject); + }); +} + +async function waitForReady(baseURL: string, deadline: number): Promise { + while (Date.now() < deadline) { + try { + const res = await fetch(`${baseURL}/status`); + if (res.ok) { + const json = await res.json() as { value?: { ready?: boolean } }; + if (json?.value?.ready !== false) + return; + } + } catch { + // safaridriver not accepting connections yet — retry. + } + await new Promise(r => setTimeout(r, 200)); + } + throw new Error(`safaridriver did not become ready at ${baseURL}`); +} + +export const webdriverTest = baseTest.extend({ + browserVersion: ['', { scope: 'worker' }], + browserMajorVersion: [0, { scope: 'worker' }], + electronMajorVersion: [0, { scope: 'worker' }], + isBidi: [false, { scope: 'worker' }], + isAndroid: [false, { scope: 'worker' }], + isElectron: [false, { scope: 'worker' }], + isHeadlessShell: [false, { scope: 'worker' }], + isFrozenWebkit: [false, { scope: 'worker' }], + + // Launch one safaridriver per worker and expose its HTTP endpoint. Each test + // then creates and tears down its own WebDriver session (a fresh Safari + // window) against it — mirroring the webview worker-endpoint / per-test-page + // split. safaridriver drives desktop Safari, so this only works on macOS with + // `safaridriver --enable` run once. + webdriverEndpoint: [async ({}, run) => { + killStraySafariDrivers(); + await new Promise(r => setTimeout(r, 1000)); + const port = await findFreePort(); + const proc: ChildProcess = spawn('safaridriver', ['--port', String(port)], { stdio: 'ignore' }); + const baseURL = `http://localhost:${port}`; + try { + await waitForReady(baseURL, Date.now() + 30000); + await run(`webdriver://localhost:${port}`); + } finally { + proc.kill('SIGTERM'); + killStraySafariDrivers(); + } + }, { scope: 'worker', timeout: 60000 }], + + page: async ({ playwright, webdriverEndpoint }, run) => { + const browser = await playwright.webkit.connectOverCDP(webdriverEndpoint); + const page = browser.contexts()[0].pages()[0]; + if (!page) + throw new Error('No Safari page is attached'); + await page.goto('about:blank').catch(() => {}); + await run(page); + await browser.close().catch(() => {}); + }, +}); From ace1cad77629d0a24f6582f01e21a37f0a900e06 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Thu, 4 Jun 2026 12:31:42 -0700 Subject: [PATCH 02/10] feat(webkit): support setContent and reliable input in WebDriver backend Makes tests/page/page-check.spec.ts pass (and unblocks setContent/console-based tests generally). - Capture console messages + document readyState as a side-channel of every execute, then synthesize console and lifecycle events. This drives Frame.setContent (which logs a console.debug tag to clear lifecycle) and resolves waitForLoadState over a protocol that pushes no events. - Dispatch input through the same injected page-side dispatcher the webview backend uses (window.__pwWebViewInput) instead of the W3C Actions API. The synthetic events are handled deterministically by Playwright's hit-target interceptor at the exact target, eliminating the intermittent click flake (only mousedown delivered) that trusted OS-level Actions events caused. --- .../webkit/webdriver/wdExecutionContext.ts | 68 ++++++--- .../src/server/webkit/webdriver/wdInput.ts | 136 +++++++++--------- .../src/server/webkit/webdriver/wdPage.ts | 36 ++++- 3 files changed, 158 insertions(+), 82 deletions(-) diff --git a/packages/playwright-core/src/server/webkit/webdriver/wdExecutionContext.ts b/packages/playwright-core/src/server/webkit/webdriver/wdExecutionContext.ts index e9e8cc9518e7e..553f36bcd414d 100644 --- a/packages/playwright-core/src/server/webkit/webdriver/wdExecutionContext.ts +++ b/packages/playwright-core/src/server/webkit/webdriver/wdExecutionContext.ts @@ -38,6 +38,30 @@ type WDHandleMeta = { // top of every script so `window.__pwHandles.get(...)` is always available. const kRegistryInit = `window.__pwHandles = window.__pwHandles || new Map(); window.__pwHandleSeq = window.__pwHandleSeq || 0;`; +// Page event captured as a side-channel of every `execute` (see kBridgeInit). +export type WDPageEvent = { type: string, text: string }; + +// Idempotently installs a console hook that buffers messages on window, so we +// can surface them (and the lifecycle-clearing tag setContent logs) over a +// protocol that pushes no console events. Lives on `window`/`console`, both of +// which survive `document.open()`. +const kBridgeInit = ` + if (!window.__pwBridge) { + window.__pwBridge = true; + window.__pwEvents = []; + for (const __pwType of ['log', 'debug', 'info', 'warning', 'error']) { + const __pwMethod = __pwType === 'warning' ? 'warn' : __pwType; + const __pwOrig = window.console[__pwMethod]; + window.console[__pwMethod] = function(...args) { + try { + window.__pwEvents.push({ type: __pwType, text: args.map(a => { try { return typeof a === 'string' ? a : JSON.stringify(a); } catch (e) { return String(a); } }).join(' ') }); + } catch (e) {} + if (__pwOrig) return __pwOrig.apply(window.console, args); + }; + } + } +`; + /** * ExecutionContextDelegate over classic W3C WebDriver `execute/async`. * @@ -50,15 +74,29 @@ const kRegistryInit = `window.__pwHandles = window.__pwHandles || new Map(); win */ export class WDExecutionContext implements js.ExecutionContextDelegate { private readonly _session: WDSession; + private readonly _onPageEvents: (events: WDPageEvent[], readyState: string) => void; private readonly _handleMeta = new Map(); - constructor(session: WDSession) { + constructor(session: WDSession, onPageEvents: (events: WDPageEvent[], readyState: string) => void) { this._session = session; + this._onPageEvents = onPageEvents; + } + + // Runs a script, then delivers any buffered console messages and the document + // readyState so the page can synthesize console + lifecycle events. + private async _execute(script: string, args: any[] = []): Promise { + const result = await this._session.executeAsync(script, args); + if (result && typeof result === 'object') { + this._onPageEvents(result.events || [], result.readyState || ''); + if ('__pwError' in result) + throw new js.JavaScriptErrorInEvaluate(String(result.__pwError)); + return result.value; + } + return undefined; } async rawEvaluateJSON(expression: string): Promise { - const result = await this._session.executeAsync(wrapAsync(`return (${expression});`)); - return unwrap(result); + return await this._execute(wrapAsync(`return (${expression});`)); } async rawEvaluateHandle(context: js.ExecutionContext, expression: string): Promise { @@ -100,8 +138,7 @@ export class WDExecutionContext implements js.ExecutionContextDelegate { `; } - const result = await this._session.executeAsync(wrapAsync(body), [values]); - const value = unwrap(result); + const value = await this._execute(wrapAsync(body), [values]); if (returnByValue) return parseEvaluationResultValue(value); return this._createHandleFromResult(utilityScript._context, value); @@ -123,8 +160,7 @@ export class WDExecutionContext implements js.ExecutionContextDelegate { } return out; }`; - const entries = await this._session.executeAsync(wrapAsync(`${kRegistryInit} return (${expression})();`)); - const list = unwrap(entries) as { name: string, id: string }[]; + const list = await this._execute(wrapAsync(`return (${expression})();`)) as { name: string, id: string }[]; const result = new Map(); for (const { name, id } of list) { const objectId = `wd-obj-${id}`; @@ -140,7 +176,7 @@ export class WDExecutionContext implements js.ExecutionContextDelegate { const meta = this._handleMeta.get(handle._objectId); this._handleMeta.delete(handle._objectId); if (meta?.registryId) - await this._session.executeAsync(wrapAsync(`window.__pwHandles && window.__pwHandles.delete(${JSON.stringify(meta.registryId)}); return undefined;`)).catch(() => {}); + await this._execute(wrapAsync(`window.__pwHandles && window.__pwHandles.delete(${JSON.stringify(meta.registryId)}); return undefined;`)).catch(() => {}); } shouldPrependErrorPrefix(): boolean { @@ -171,20 +207,20 @@ export class WDExecutionContext implements js.ExecutionContextDelegate { // Wraps a body into an `execute/async` script: the last argument is the // completion callback. Lets the page function return a promise and surfaces -// thrown errors as `{ __pwError }` rather than hanging. +// thrown errors as `{ __pwError }` rather than hanging. Every result also +// carries buffered console events and the document readyState, so the page can +// synthesize console + lifecycle events that WebDriver never pushes. function wrapAsync(body: string): string { return ` const __pwArgs = arguments; const __pwDone = __pwArgs[__pwArgs.length - 1]; ${kRegistryInit} + ${kBridgeInit} + const __pwDrain = () => { const e = window.__pwEvents || []; window.__pwEvents = []; return { events: e, readyState: document.readyState }; }; (async () => { ${body} - })().then(value => __pwDone({ value }), error => __pwDone({ __pwError: (error && error.stack) || String(error) })); + })().then( + value => __pwDone({ value, ...__pwDrain() }), + error => __pwDone({ __pwError: (error && error.stack) || String(error), ...__pwDrain() })); `; } - -function unwrap(result: any): any { - if (result && typeof result === 'object' && '__pwError' in result) - throw new js.JavaScriptErrorInEvaluate(String(result.__pwError)); - return result ? result.value : undefined; -} diff --git a/packages/playwright-core/src/server/webkit/webdriver/wdInput.ts b/packages/playwright-core/src/server/webkit/webdriver/wdInput.ts index 4186366a21e9a..b182b35cd38e5 100644 --- a/packages/playwright-core/src/server/webkit/webdriver/wdInput.ts +++ b/packages/playwright-core/src/server/webkit/webdriver/wdInput.ts @@ -20,28 +20,22 @@ import type * as types from '../../types'; import type { WDSession } from './wdConnection'; import type { Progress } from '../../progress'; -// W3C WebDriver normalized key values for non-printable keys (U+E000..U+E05D). -// https://www.w3.org/TR/webdriver/#keyboard-actions -const kW3CKeys: Record = { - 'Cancel': '\uE001', 'Help': '\uE002', 'Backspace': '\uE003', 'Tab': '\uE004', 'Clear': '\uE005', - 'Enter': '\uE007', 'Shift': '\uE008', 'Control': '\uE009', 'Alt': '\uE00A', 'Pause': '\uE00B', 'Escape': '\uE00C', - 'PageUp': '\uE00E', 'PageDown': '\uE00F', 'End': '\uE010', 'Home': '\uE011', - 'ArrowLeft': '\uE012', 'ArrowUp': '\uE013', 'ArrowRight': '\uE014', 'ArrowDown': '\uE015', - 'Insert': '\uE016', 'Delete': '\uE017', 'Meta': '\uE03D', - 'F1': '\uE031', 'F2': '\uE032', 'F3': '\uE033', 'F4': '\uE034', 'F5': '\uE035', 'F6': '\uE036', - 'F7': '\uE037', 'F8': '\uE038', 'F9': '\uE039', 'F10': '\uE03A', 'F11': '\uE03B', 'F12': '\uE03C', -}; - -function w3cKeyValue(description: input.KeyDescription): string { - if (kW3CKeys[description.key]) - return kW3CKeys[description.key]; - // Printable keys: the single-character key value carries the right glyph. - if (description.key.length === 1) - return description.key; - return description.text || description.key; +// Input is dispatched through the same page-side dispatcher the webview backend +// uses (window.__pwWebViewInput, installed by WDPage). It synthesizes DOM events +// at the exact hit target, marked __pwTrustedSynthetic so Playwright's +// hit-target interceptor handles them deterministically — avoiding the timing +// flakiness of the W3C Actions API while still toggling form controls. + +function modifierFlags(modifiers: Set) { + return { + ctrlKey: modifiers.has('Control'), + shiftKey: modifiers.has('Shift'), + altKey: modifiers.has('Alt'), + metaKey: modifiers.has('Meta'), + }; } -function buttonNumber(button: types.MouseButton): number { +function buttonToNumber(button: types.MouseButton | 'none'): number { if (button === 'middle') return 1; if (button === 'right') @@ -49,86 +43,98 @@ function buttonNumber(button: types.MouseButton): number { return 0; } -/** - * Input over the W3C WebDriver Actions API (`POST /session/{id}/actions`). - * - * WebDriver keeps per-session input state between `performActions` calls, so the - * separate move/down/up delegate calls that Playwright issues accumulate - * correctly. Keyboard modifiers are already pressed as real key actions by - * Playwright's input layer, so the mouse handlers do not re-apply them. - */ +function toButtonsMask(buttons: Set): number { + let mask = 0; + if (buttons.has('left')) + mask |= 1; + if (buttons.has('right')) + mask |= 2; + if (buttons.has('middle')) + mask |= 4; + return mask; +} + +async function callInput(progress: Progress, session: WDSession | undefined, method: string, arg: any): Promise { + if (!session) + throw new Error('Page is not initialized'); + // Awaits the dispatcher's returned promise (some dispatches span event-loop + // tasks) via execute/async, so the action only resolves once delivered. + const script = ` + const __cb = arguments[arguments.length - 1]; + if (!window.__pwWebViewInput) { __cb({ __pwError: 'input dispatcher not installed' }); } + else Promise.resolve(window.__pwWebViewInput.${method}(${JSON.stringify(arg)})).then(() => __cb(null), e => __cb({ __pwError: String(e) })); + `; + const result = await progress.race(session.executeAsync(script)); + if (result && result.__pwError) + throw new Error(result.__pwError); +} + class RawInput { protected _session: WDSession | undefined; setSession(session: WDSession) { this._session = session; } - - protected async _perform(progress: Progress, actions: any[]): Promise { - if (!this._session) - throw new Error('Page is not initialized'); - await progress.race(this._session.performActions(actions)); - } } export class RawKeyboardImpl extends RawInput implements input.RawKeyboard { async keydown(progress: Progress, modifiers: Set, keyName: string, description: input.KeyDescription, autoRepeat: boolean): Promise { - await this._key(progress, 'keyDown', w3cKeyValue(description)); + const { code, keyCode, key, text, location } = description; + await callInput(progress, this._session, 'keydown', { + code, key, keyCode, location, repeat: autoRepeat, + ...modifierFlags(modifiers), + ...(text ? { text } : {}), + }); } async keyup(progress: Progress, modifiers: Set, keyName: string, description: input.KeyDescription): Promise { - await this._key(progress, 'keyUp', w3cKeyValue(description)); + const { code, keyCode, key, location } = description; + await callInput(progress, this._session, 'keyup', { code, key, keyCode, location, ...modifierFlags(modifiers) }); } async sendText(progress: Progress, text: string): Promise { - const actions = []; - for (const char of text) - actions.push({ type: 'keyDown', value: char }, { type: 'keyUp', value: char }); - await this._perform(progress, [{ type: 'key', id: 'keyboard', actions }]); - } - - private async _key(progress: Progress, type: 'keyDown' | 'keyUp', value: string): Promise { - await this._perform(progress, [{ type: 'key', id: 'keyboard', actions: [{ type, value }] }]); + await callInput(progress, this._session, 'insertText', text); } } export class RawMouseImpl extends RawInput implements input.RawMouse { async move(progress: Progress, x: number, y: number, button: types.MouseButton | 'none', buttons: Set, modifiers: Set, forClick: boolean): Promise { - await this._pointer(progress, [{ type: 'pointerMove', duration: 0, origin: 'viewport', x: Math.round(x), y: Math.round(y) }]); + await callInput(progress, this._session, 'mouseMove', { + x, y, button: buttonToNumber(button), buttons: toButtonsMask(buttons), ...modifierFlags(modifiers), + }); } async down(progress: Progress, x: number, y: number, button: types.MouseButton, buttons: Set, modifiers: Set, clickCount: number): Promise { - await this._pointer(progress, [{ type: 'pointerDown', button: buttonNumber(button) }]); + const buttonCode = buttonToNumber(button); + const buttonsMask = toButtonsMask(buttons); + await this._mouseEvent(progress, 'mousedown', x, y, buttonCode, buttonsMask, modifiers, clickCount); + if (button === 'right') + await this._mouseEvent(progress, 'contextmenu', x, y, buttonCode, buttonsMask, modifiers, clickCount); } async up(progress: Progress, x: number, y: number, button: types.MouseButton, buttons: Set, modifiers: Set, clickCount: number): Promise { - await this._pointer(progress, [{ type: 'pointerUp', button: buttonNumber(button) }]); + const buttonCode = buttonToNumber(button); + const buttonsMask = toButtonsMask(buttons); + await this._mouseEvent(progress, 'mouseup', x, y, buttonCode, buttonsMask, modifiers, clickCount); + if (clickCount > 0) { + const clickType = button === 'left' ? 'click' : 'auxclick'; + await this._mouseEvent(progress, clickType, x, y, buttonCode, buttonsMask, modifiers, clickCount); + if (clickCount === 2) + await this._mouseEvent(progress, 'dblclick', x, y, buttonCode, buttonsMask, modifiers, clickCount); + } } async wheel(progress: Progress, x: number, y: number, buttons: Set, modifiers: Set, deltaX: number, deltaY: number): Promise { - await this._perform(progress, [{ - type: 'wheel', - id: 'wheel', - actions: [{ type: 'scroll', x: Math.round(x), y: Math.round(y), deltaX: Math.round(deltaX), deltaY: Math.round(deltaY), origin: 'viewport' }], - }]); + await callInput(progress, this._session, 'wheel', { x, y, deltaX, deltaY, ...modifierFlags(modifiers) }); } - private async _pointer(progress: Progress, actions: any[]): Promise { - await this._perform(progress, [{ type: 'pointer', id: 'mouse', parameters: { pointerType: 'mouse' }, actions }]); + private async _mouseEvent(progress: Progress, type: string, x: number, y: number, button: number, buttons: number, modifiers: Set, clickCount: number) { + await callInput(progress, this._session, 'mouseEvent', { type, x, y, button, buttons, clickCount, ...modifierFlags(modifiers) }); } } export class RawTouchscreenImpl extends RawInput implements input.RawTouchscreen { - async tap(progress: Progress, x: number, y: number, modifiers: Set): Promise { - await this._perform(progress, [{ - type: 'pointer', - id: 'finger', - parameters: { pointerType: 'touch' }, - actions: [ - { type: 'pointerMove', duration: 0, origin: 'viewport', x: Math.round(x), y: Math.round(y) }, - { type: 'pointerDown', button: 0 }, - { type: 'pointerUp', button: 0 }, - ], - }]); + async tap(progress: Progress, x: number, y: number, modifiers: Set) { + await callInput(progress, this._session, 'tap', { x, y, ...modifierFlags(modifiers) }); } } diff --git a/packages/playwright-core/src/server/webkit/webdriver/wdPage.ts b/packages/playwright-core/src/server/webkit/webdriver/wdPage.ts index edad10e5c2f73..df381254df2d5 100644 --- a/packages/playwright-core/src/server/webkit/webdriver/wdPage.ts +++ b/packages/playwright-core/src/server/webkit/webdriver/wdPage.ts @@ -20,9 +20,11 @@ import { ManualPromise } from '@isomorphic/manualPromise'; import { createGuid } from '@utils/crypto'; import * as dom from '../../dom'; import { Page } from '../../page'; +import * as rawWebViewInputSource from '../../../generated/webViewInputSource'; import { WDExecutionContext } from './wdExecutionContext'; import { RawKeyboardImpl, RawMouseImpl, RawTouchscreenImpl } from './wdInput'; +import type { WDPageEvent } from './wdExecutionContext'; import type { WDBrowserContext } from './wdBrowser'; import type { WDSession } from './wdConnection'; import type * as frames from '../../frames'; @@ -79,6 +81,7 @@ export class WDPage implements PageDelegate { this._mainFrameId = await this._session.windowHandle().catch(() => 'main'); this._page.frameManager.frameAttached(this._mainFrameId, null); this._createContext(); + await this._installInputBridge(); const url = await this._session.currentUrl().catch(() => 'about:blank'); this._page.frameManager.frameCommittedNewDocumentNavigation(this._mainFrameId, url, '', createGuid(), true); this._page.frameManager.frameLifecycleEvent(this._mainFrameId, 'domcontentloaded'); @@ -97,11 +100,41 @@ export class WDPage implements PageDelegate { private _createContext(): void { const frame = this._mainFrame(); - const context = new dom.FrameExecutionContext(new WDExecutionContext(this._session), frame, 'main'); + const delegate = new WDExecutionContext(this._session, (events, readyState) => this._processPageEvents(events, readyState)); + const context = new dom.FrameExecutionContext(delegate, frame, 'main'); this._context = context; frame.contextCreated('main', context); } + // Install the page-side input dispatcher (see wdInput) into the current + // document. Re-installed after every navigation, since a new document drops it. + private async _installInputBridge(): Promise { + const bootstrap = `(() => { + const module = {}; + ${rawWebViewInputSource.source} + window.__pwWebViewInput = new (module.exports.default())(window, document); + })()`; + const script = ` + const __cb = arguments[arguments.length - 1]; + try { ${bootstrap}; __cb(null); } catch (e) { __cb({ __pwError: String(e) }); } + `; + await this._session.executeAsync(script).catch(() => {}); + } + + // WebDriver pushes no console/lifecycle events, so every evaluate piggybacks + // buffered console messages and the document readyState (see wdExecutionContext). + // Delivering the console messages drives the setContent tag handler (which + // clears lifecycle), after which we re-fire lifecycle from readyState. + private _processPageEvents(events: WDPageEvent[], readyState: string): void { + const location = { url: this._mainFrame().url(), lineNumber: 0, columnNumber: 0 }; + for (const event of events) + this._page.addConsoleMessage(null, event.type, [], location, event.text, Date.now()); + if (readyState === 'interactive' || readyState === 'complete') + this._page.frameManager.frameLifecycleEvent(this._mainFrameId, 'domcontentloaded'); + if (readyState === 'complete') + this._page.frameManager.frameLifecycleEvent(this._mainFrameId, 'load'); + } + // Synthesizes the commit + context + lifecycle events for a navigation that // the (blocking) WebDriver command has already completed. Returns the freshly // generated document id so navigateFrame can satisfy gotoImpl's predicate. @@ -115,6 +148,7 @@ export class WDPage implements PageDelegate { const documentId = createGuid(); this._page.frameManager.frameCommittedNewDocumentNavigation(this._mainFrameId, url, '', documentId, false); this._createContext(); + await this._installInputBridge(); this._page.frameManager.frameLifecycleEvent(this._mainFrameId, 'domcontentloaded'); this._page.frameManager.frameLifecycleEvent(this._mainFrameId, 'load'); return { newDocumentId: documentId }; From 763dfa821dd78db2ef8ac06fc44d5867bc31a45d Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Thu, 4 Jun 2026 14:19:27 -0700 Subject: [PATCH 03/10] fix(webkit): use native WebDriver input and serialize commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switches the WebDriver backend back to the native W3C Actions API for input (instead of the injected synthetic dispatcher) and fixes the real root cause of the intermittent dropped clicks. - Serialize all WebDriver commands onto a single chain. safaridriver processes one command per session at a time; Playwright issued some concurrently (races, lifecycle-triggered follow-ups), and overlapping /actions + /execute requests were reordered by the driver — landing pointerUp a cycle late and swallowing parts of a click. Serializing eliminates the desync. - Cache UtilityScript/InjectedScript as per-document singletons in the page registry instead of rebuilding them (and re-installing their global event listeners) on every evaluate. - Test fixture: reuse one session per worker and reset to about:blank between tests, and stop safaridriver gracefully (SIGTERM + await exit, never SIGKILL) so it deletes its session and unpairs Safari — avoiding the "Continue Session" dialog / "already paired" failures on the next run. --- .../server/webkit/webdriver/wdConnection.ts | 14 +- .../webkit/webdriver/wdExecutionContext.ts | 45 +++--- .../src/server/webkit/webdriver/wdInput.ts | 136 +++++++++--------- .../src/server/webkit/webdriver/wdPage.ts | 18 --- tests/webdriver/webdriverTest.ts | 45 ++++-- 5 files changed, 132 insertions(+), 126 deletions(-) diff --git a/packages/playwright-core/src/server/webkit/webdriver/wdConnection.ts b/packages/playwright-core/src/server/webkit/webdriver/wdConnection.ts index 7945016949ae2..e4b5eca998b86 100644 --- a/packages/playwright-core/src/server/webkit/webdriver/wdConnection.ts +++ b/packages/playwright-core/src/server/webkit/webdriver/wdConnection.ts @@ -34,6 +34,11 @@ export class WDConnection { private readonly _protocolLogger: ProtocolLogger; private readonly _browserLogsCollector: RecentLogsCollector; private _closed = false; + // safaridriver processes one command per session at a time. Playwright can + // issue commands concurrently (races, lifecycle-triggered follow-ups), and + // overlapping requests get reordered by the driver — e.g. a pointerUp landing + // a cycle late, breaking clicks. Serialize every command onto a single chain. + private _commandChain: Promise = Promise.resolve(); constructor(baseURL: string, protocolLogger: ProtocolLogger, browserLogsCollector: RecentLogsCollector) { // Normalize a trailing slash so callers can build `${baseURL}/session/...`. @@ -42,7 +47,14 @@ export class WDConnection { this._browserLogsCollector = browserLogsCollector; } - async command(httpMethod: 'GET' | 'POST' | 'DELETE', path: string, body?: any): Promise { + command(httpMethod: 'GET' | 'POST' | 'DELETE', path: string, body?: any): Promise { + const result = this._commandChain.then(() => this._sendCommand(httpMethod, path, body)); + // Keep the chain alive regardless of this command's outcome. + this._commandChain = result.then(() => {}, () => {}); + return result; + } + + private async _sendCommand(httpMethod: 'GET' | 'POST' | 'DELETE', path: string, body?: any): Promise { if (this._closed) throw this._error('closed', undefined, 'WebDriver connection is closed'); const url = `${this.baseURL}${path}`; diff --git a/packages/playwright-core/src/server/webkit/webdriver/wdExecutionContext.ts b/packages/playwright-core/src/server/webkit/webdriver/wdExecutionContext.ts index 553f36bcd414d..38ff2b430a012 100644 --- a/packages/playwright-core/src/server/webkit/webdriver/wdExecutionContext.ts +++ b/packages/playwright-core/src/server/webkit/webdriver/wdExecutionContext.ts @@ -20,18 +20,12 @@ import * as dom from '../../dom'; import type { WDSession } from './wdConnection'; -let lastHandleId = 0; - // Metadata we attach to a JSHandle so it can be re-materialized inside a -// WebDriver `execute` call. A handle is one of: -// - `source`: a self-contained IIFE we inline to reconstruct the object -// (UtilityScript, InjectedScript) — stateless, rebuilt per call. -// - `registryId`: a key into the page-side `window.__pwHandles` map, which is -// how we emulate live remote handles over a protocol that has -// none. Valid until the document navigates. +// WebDriver `execute` call. `registryId` is a key into the page-side +// `window.__pwHandles` map, which is how we emulate live remote handles over a +// protocol that has none. Valid until the document navigates. type WDHandleMeta = { - source?: string; - registryId?: string; + registryId: string; }; // Lazily creates the page-side live-handle registry. Idempotent; included at the @@ -67,10 +61,12 @@ const kBridgeInit = ` * * WebDriver has no persistent object handles, so we emulate them with a page-side * registry (`window.__pwHandles`): when an evaluate returns a non-serializable - * value (a DOM node, the InjectedScript hit-target interceptor, …) we stash it in - * the registry and return its key. Passing that handle back inlines a - * `window.__pwHandles.get(key)` lookup, so the live object is recovered in-page. - * UtilityScript/InjectedScript are instead inlined from source each call. + * value (a DOM node, UtilityScript/InjectedScript, the hit-target interceptor, …) + * we stash it in the registry and return its key. Passing that handle back inlines + * a `window.__pwHandles.get(key)` lookup, so the live object is recovered in-page. + * Crucially this caches UtilityScript/InjectedScript as per-document singletons + * rather than rebuilding them (and re-installing their global event listeners) on + * every evaluate. */ export class WDExecutionContext implements js.ExecutionContextDelegate { private readonly _session: WDSession; @@ -100,12 +96,17 @@ export class WDExecutionContext implements js.ExecutionContextDelegate { } async rawEvaluateHandle(context: js.ExecutionContext, expression: string): Promise { - // The only callers are `_utilityScript()` / `injectedScript()`, which pass a - // self-contained IIFE source. Stash the source on a placeholder handle and - // inline it on every evaluate that references it. - const objectId = `wd-source-${++lastHandleId}`; - this._handleMeta.set(objectId, { source: expression }); - return new js.JSHandle(context, 'object', undefined, objectId); + // The callers are `_utilityScript()` / `injectedScript()`, passing a + // self-contained IIFE source. Evaluate it once and keep the resulting object + // live in the page registry, so it is reused (not rebuilt) on every evaluate. + const expr = expression.trim().replace(/;+\s*$/, ''); + const value = await this._execute(wrapAsync(` + const __pwR = (${expr}); + const __pwId = 'h' + (++window.__pwHandleSeq); + window.__pwHandles.set(__pwId, __pwR); + return { __pwHandleId: __pwId, isElement: false }; + `)); + return this._createHandleFromResult(context, value); } async evaluateWithArguments(expression: string, returnByValue: boolean, utilityScript: js.JSHandle, values: any[], handles: js.JSHandle[]): Promise { @@ -186,8 +187,6 @@ export class WDExecutionContext implements js.ExecutionContextDelegate { // Builds the JS expression that recovers a handle inside a WebDriver script. private _inlineHandle(handle: js.JSHandle): string { const meta = handle._objectId ? this._handleMeta.get(handle._objectId) : undefined; - if (meta?.source) - return `(${meta.source.trim().replace(/;+\s*$/, '')})`; if (meta?.registryId) return `window.__pwHandles.get(${JSON.stringify(meta.registryId)})`; throw new js.JavaScriptErrorInEvaluate('Cannot pass an unknown JSHandle to a WebDriver evaluate.'); @@ -196,7 +195,7 @@ export class WDExecutionContext implements js.ExecutionContextDelegate { private _createHandleFromResult(context: js.ExecutionContext, descriptor: any): js.JSHandle { if (descriptor && descriptor.__pwPrimitive) return new js.JSHandle(context, typeof descriptor.value, undefined, undefined, descriptor.value); - const registryId = descriptor?.__pwHandleId as string | undefined; + const registryId = descriptor?.__pwHandleId as string; const objectId = `wd-obj-${registryId}`; this._handleMeta.set(objectId, { registryId }); if (descriptor?.isElement && context instanceof dom.FrameExecutionContext) diff --git a/packages/playwright-core/src/server/webkit/webdriver/wdInput.ts b/packages/playwright-core/src/server/webkit/webdriver/wdInput.ts index b182b35cd38e5..4186366a21e9a 100644 --- a/packages/playwright-core/src/server/webkit/webdriver/wdInput.ts +++ b/packages/playwright-core/src/server/webkit/webdriver/wdInput.ts @@ -20,22 +20,28 @@ import type * as types from '../../types'; import type { WDSession } from './wdConnection'; import type { Progress } from '../../progress'; -// Input is dispatched through the same page-side dispatcher the webview backend -// uses (window.__pwWebViewInput, installed by WDPage). It synthesizes DOM events -// at the exact hit target, marked __pwTrustedSynthetic so Playwright's -// hit-target interceptor handles them deterministically — avoiding the timing -// flakiness of the W3C Actions API while still toggling form controls. - -function modifierFlags(modifiers: Set) { - return { - ctrlKey: modifiers.has('Control'), - shiftKey: modifiers.has('Shift'), - altKey: modifiers.has('Alt'), - metaKey: modifiers.has('Meta'), - }; +// W3C WebDriver normalized key values for non-printable keys (U+E000..U+E05D). +// https://www.w3.org/TR/webdriver/#keyboard-actions +const kW3CKeys: Record = { + 'Cancel': '\uE001', 'Help': '\uE002', 'Backspace': '\uE003', 'Tab': '\uE004', 'Clear': '\uE005', + 'Enter': '\uE007', 'Shift': '\uE008', 'Control': '\uE009', 'Alt': '\uE00A', 'Pause': '\uE00B', 'Escape': '\uE00C', + 'PageUp': '\uE00E', 'PageDown': '\uE00F', 'End': '\uE010', 'Home': '\uE011', + 'ArrowLeft': '\uE012', 'ArrowUp': '\uE013', 'ArrowRight': '\uE014', 'ArrowDown': '\uE015', + 'Insert': '\uE016', 'Delete': '\uE017', 'Meta': '\uE03D', + 'F1': '\uE031', 'F2': '\uE032', 'F3': '\uE033', 'F4': '\uE034', 'F5': '\uE035', 'F6': '\uE036', + 'F7': '\uE037', 'F8': '\uE038', 'F9': '\uE039', 'F10': '\uE03A', 'F11': '\uE03B', 'F12': '\uE03C', +}; + +function w3cKeyValue(description: input.KeyDescription): string { + if (kW3CKeys[description.key]) + return kW3CKeys[description.key]; + // Printable keys: the single-character key value carries the right glyph. + if (description.key.length === 1) + return description.key; + return description.text || description.key; } -function buttonToNumber(button: types.MouseButton | 'none'): number { +function buttonNumber(button: types.MouseButton): number { if (button === 'middle') return 1; if (button === 'right') @@ -43,98 +49,86 @@ function buttonToNumber(button: types.MouseButton | 'none'): number { return 0; } -function toButtonsMask(buttons: Set): number { - let mask = 0; - if (buttons.has('left')) - mask |= 1; - if (buttons.has('right')) - mask |= 2; - if (buttons.has('middle')) - mask |= 4; - return mask; -} - -async function callInput(progress: Progress, session: WDSession | undefined, method: string, arg: any): Promise { - if (!session) - throw new Error('Page is not initialized'); - // Awaits the dispatcher's returned promise (some dispatches span event-loop - // tasks) via execute/async, so the action only resolves once delivered. - const script = ` - const __cb = arguments[arguments.length - 1]; - if (!window.__pwWebViewInput) { __cb({ __pwError: 'input dispatcher not installed' }); } - else Promise.resolve(window.__pwWebViewInput.${method}(${JSON.stringify(arg)})).then(() => __cb(null), e => __cb({ __pwError: String(e) })); - `; - const result = await progress.race(session.executeAsync(script)); - if (result && result.__pwError) - throw new Error(result.__pwError); -} - +/** + * Input over the W3C WebDriver Actions API (`POST /session/{id}/actions`). + * + * WebDriver keeps per-session input state between `performActions` calls, so the + * separate move/down/up delegate calls that Playwright issues accumulate + * correctly. Keyboard modifiers are already pressed as real key actions by + * Playwright's input layer, so the mouse handlers do not re-apply them. + */ class RawInput { protected _session: WDSession | undefined; setSession(session: WDSession) { this._session = session; } + + protected async _perform(progress: Progress, actions: any[]): Promise { + if (!this._session) + throw new Error('Page is not initialized'); + await progress.race(this._session.performActions(actions)); + } } export class RawKeyboardImpl extends RawInput implements input.RawKeyboard { async keydown(progress: Progress, modifiers: Set, keyName: string, description: input.KeyDescription, autoRepeat: boolean): Promise { - const { code, keyCode, key, text, location } = description; - await callInput(progress, this._session, 'keydown', { - code, key, keyCode, location, repeat: autoRepeat, - ...modifierFlags(modifiers), - ...(text ? { text } : {}), - }); + await this._key(progress, 'keyDown', w3cKeyValue(description)); } async keyup(progress: Progress, modifiers: Set, keyName: string, description: input.KeyDescription): Promise { - const { code, keyCode, key, location } = description; - await callInput(progress, this._session, 'keyup', { code, key, keyCode, location, ...modifierFlags(modifiers) }); + await this._key(progress, 'keyUp', w3cKeyValue(description)); } async sendText(progress: Progress, text: string): Promise { - await callInput(progress, this._session, 'insertText', text); + const actions = []; + for (const char of text) + actions.push({ type: 'keyDown', value: char }, { type: 'keyUp', value: char }); + await this._perform(progress, [{ type: 'key', id: 'keyboard', actions }]); + } + + private async _key(progress: Progress, type: 'keyDown' | 'keyUp', value: string): Promise { + await this._perform(progress, [{ type: 'key', id: 'keyboard', actions: [{ type, value }] }]); } } export class RawMouseImpl extends RawInput implements input.RawMouse { async move(progress: Progress, x: number, y: number, button: types.MouseButton | 'none', buttons: Set, modifiers: Set, forClick: boolean): Promise { - await callInput(progress, this._session, 'mouseMove', { - x, y, button: buttonToNumber(button), buttons: toButtonsMask(buttons), ...modifierFlags(modifiers), - }); + await this._pointer(progress, [{ type: 'pointerMove', duration: 0, origin: 'viewport', x: Math.round(x), y: Math.round(y) }]); } async down(progress: Progress, x: number, y: number, button: types.MouseButton, buttons: Set, modifiers: Set, clickCount: number): Promise { - const buttonCode = buttonToNumber(button); - const buttonsMask = toButtonsMask(buttons); - await this._mouseEvent(progress, 'mousedown', x, y, buttonCode, buttonsMask, modifiers, clickCount); - if (button === 'right') - await this._mouseEvent(progress, 'contextmenu', x, y, buttonCode, buttonsMask, modifiers, clickCount); + await this._pointer(progress, [{ type: 'pointerDown', button: buttonNumber(button) }]); } async up(progress: Progress, x: number, y: number, button: types.MouseButton, buttons: Set, modifiers: Set, clickCount: number): Promise { - const buttonCode = buttonToNumber(button); - const buttonsMask = toButtonsMask(buttons); - await this._mouseEvent(progress, 'mouseup', x, y, buttonCode, buttonsMask, modifiers, clickCount); - if (clickCount > 0) { - const clickType = button === 'left' ? 'click' : 'auxclick'; - await this._mouseEvent(progress, clickType, x, y, buttonCode, buttonsMask, modifiers, clickCount); - if (clickCount === 2) - await this._mouseEvent(progress, 'dblclick', x, y, buttonCode, buttonsMask, modifiers, clickCount); - } + await this._pointer(progress, [{ type: 'pointerUp', button: buttonNumber(button) }]); } async wheel(progress: Progress, x: number, y: number, buttons: Set, modifiers: Set, deltaX: number, deltaY: number): Promise { - await callInput(progress, this._session, 'wheel', { x, y, deltaX, deltaY, ...modifierFlags(modifiers) }); + await this._perform(progress, [{ + type: 'wheel', + id: 'wheel', + actions: [{ type: 'scroll', x: Math.round(x), y: Math.round(y), deltaX: Math.round(deltaX), deltaY: Math.round(deltaY), origin: 'viewport' }], + }]); } - private async _mouseEvent(progress: Progress, type: string, x: number, y: number, button: number, buttons: number, modifiers: Set, clickCount: number) { - await callInput(progress, this._session, 'mouseEvent', { type, x, y, button, buttons, clickCount, ...modifierFlags(modifiers) }); + private async _pointer(progress: Progress, actions: any[]): Promise { + await this._perform(progress, [{ type: 'pointer', id: 'mouse', parameters: { pointerType: 'mouse' }, actions }]); } } export class RawTouchscreenImpl extends RawInput implements input.RawTouchscreen { - async tap(progress: Progress, x: number, y: number, modifiers: Set) { - await callInput(progress, this._session, 'tap', { x, y, ...modifierFlags(modifiers) }); + async tap(progress: Progress, x: number, y: number, modifiers: Set): Promise { + await this._perform(progress, [{ + type: 'pointer', + id: 'finger', + parameters: { pointerType: 'touch' }, + actions: [ + { type: 'pointerMove', duration: 0, origin: 'viewport', x: Math.round(x), y: Math.round(y) }, + { type: 'pointerDown', button: 0 }, + { type: 'pointerUp', button: 0 }, + ], + }]); } } diff --git a/packages/playwright-core/src/server/webkit/webdriver/wdPage.ts b/packages/playwright-core/src/server/webkit/webdriver/wdPage.ts index df381254df2d5..9fcae8198e2b1 100644 --- a/packages/playwright-core/src/server/webkit/webdriver/wdPage.ts +++ b/packages/playwright-core/src/server/webkit/webdriver/wdPage.ts @@ -20,7 +20,6 @@ import { ManualPromise } from '@isomorphic/manualPromise'; import { createGuid } from '@utils/crypto'; import * as dom from '../../dom'; import { Page } from '../../page'; -import * as rawWebViewInputSource from '../../../generated/webViewInputSource'; import { WDExecutionContext } from './wdExecutionContext'; import { RawKeyboardImpl, RawMouseImpl, RawTouchscreenImpl } from './wdInput'; @@ -81,7 +80,6 @@ export class WDPage implements PageDelegate { this._mainFrameId = await this._session.windowHandle().catch(() => 'main'); this._page.frameManager.frameAttached(this._mainFrameId, null); this._createContext(); - await this._installInputBridge(); const url = await this._session.currentUrl().catch(() => 'about:blank'); this._page.frameManager.frameCommittedNewDocumentNavigation(this._mainFrameId, url, '', createGuid(), true); this._page.frameManager.frameLifecycleEvent(this._mainFrameId, 'domcontentloaded'); @@ -106,21 +104,6 @@ export class WDPage implements PageDelegate { frame.contextCreated('main', context); } - // Install the page-side input dispatcher (see wdInput) into the current - // document. Re-installed after every navigation, since a new document drops it. - private async _installInputBridge(): Promise { - const bootstrap = `(() => { - const module = {}; - ${rawWebViewInputSource.source} - window.__pwWebViewInput = new (module.exports.default())(window, document); - })()`; - const script = ` - const __cb = arguments[arguments.length - 1]; - try { ${bootstrap}; __cb(null); } catch (e) { __cb({ __pwError: String(e) }); } - `; - await this._session.executeAsync(script).catch(() => {}); - } - // WebDriver pushes no console/lifecycle events, so every evaluate piggybacks // buffered console messages and the document readyState (see wdExecutionContext). // Delivering the console messages drives the setContent tag handler (which @@ -148,7 +131,6 @@ export class WDPage implements PageDelegate { const documentId = createGuid(); this._page.frameManager.frameCommittedNewDocumentNavigation(this._mainFrameId, url, '', documentId, false); this._createContext(); - await this._installInputBridge(); this._page.frameManager.frameLifecycleEvent(this._mainFrameId, 'domcontentloaded'); this._page.frameManager.frameLifecycleEvent(this._mainFrameId, 'load'); return { newDocumentId: documentId }; diff --git a/tests/webdriver/webdriverTest.ts b/tests/webdriver/webdriverTest.ts index 339547bf334e3..7762e9505c673 100644 --- a/tests/webdriver/webdriverTest.ts +++ b/tests/webdriver/webdriverTest.ts @@ -19,17 +19,25 @@ import net from 'net'; import { baseTest } from '../config/baseTest'; +import type { Browser } from '@playwright/test'; import type { PageTestFixtures, PageWorkerFixtures } from '../page/pageTestApi'; export { expect } from '@playwright/test'; type WebDriverWorkerFixtures = PageWorkerFixtures & { webdriverEndpoint: string; + wdBrowser: Browser; }; function killStraySafariDrivers(): void { - // A crashed run can leave safaridriver alive with Safari still "paired", - // which makes the next session creation fail. Clear it before launching. - spawnSync('pkill', ['-9', '-f', 'safaridriver']); + // Soft-terminate (SIGTERM, not SIGKILL) so a leftover driver ends its session + // and unpairs Safari on the way out. A SIGKILLed driver leaves Safari paired, + // which makes the next session creation fail or pop the "Continue Session" + // dialog. + spawnSync('pkill', ['-f', 'safaridriver']); +} + +function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); } function findFreePort(): Promise { @@ -70,14 +78,17 @@ export const webdriverTest = baseTest.extend { + // Clear any driver orphaned by a previous crashed run, giving it a moment + // to unpair Safari before we launch a fresh one. killStraySafariDrivers(); - await new Promise(r => setTimeout(r, 1000)); + await sleep(1000); const port = await findFreePort(); const proc: ChildProcess = spawn('safaridriver', ['--port', String(port)], { stdio: 'ignore' }); const baseURL = `http://localhost:${port}`; @@ -85,18 +96,26 @@ export const webdriverTest = baseTest.extend(r => proc.once('exit', () => r())), sleep(3000)]); } }, { scope: 'worker', timeout: 60000 }], - page: async ({ playwright, webdriverEndpoint }, run) => { + wdBrowser: [async ({ playwright, webdriverEndpoint }, run) => { const browser = await playwright.webkit.connectOverCDP(webdriverEndpoint); - const page = browser.contexts()[0].pages()[0]; + try { + await run(browser); + } finally { + await browser.close().catch(() => {}); + } + }, { scope: 'worker', timeout: 60000 }], + + page: async ({ wdBrowser }, run) => { + const page = wdBrowser.contexts()[0].pages()[0]; if (!page) throw new Error('No Safari page is attached'); await page.goto('about:blank').catch(() => {}); await run(page); - await browser.close().catch(() => {}); }, }); From 4965b76ef1efde0d2b4024426112cb6cd53437a2 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Thu, 4 Jun 2026 15:00:53 -0700 Subject: [PATCH 04/10] chore(webkit): tidy WebDriver backend, reuse session, retry session creation - Trim verbose comments and drop dead WDSession methods. - Reuse one WebDriver session per worker (resetting to about:blank between tests): creating a session re-prompts Safari's "remotely controlled" dialog, so opening it once per worker minimises the prompt versus a fresh session per test. - Retry session creation while Safari still reports the previous session as paired, so a just-released session doesn't fail the next connect. --- .../src/server/webkit/webdriver/wdBrowser.ts | 20 ++++++- .../server/webkit/webdriver/wdConnection.ts | 47 ++++------------ .../webkit/webdriver/wdExecutionContext.ts | 53 +++++++------------ .../src/server/webkit/webdriver/wdInput.ts | 10 +--- .../src/server/webkit/webdriver/wdPage.ts | 22 ++++---- tests/webdriver/webdriverTest.ts | 16 +++--- 6 files changed, 64 insertions(+), 104 deletions(-) diff --git a/packages/playwright-core/src/server/webkit/webdriver/wdBrowser.ts b/packages/playwright-core/src/server/webkit/webdriver/wdBrowser.ts index c236b3b0ae371..9ecc41871eb25 100644 --- a/packages/playwright-core/src/server/webkit/webdriver/wdBrowser.ts +++ b/packages/playwright-core/src/server/webkit/webdriver/wdBrowser.ts @@ -37,11 +37,27 @@ import type * as channels from '@protocol/channels'; const kLaunchScheme = 'webdriver+launch://'; -// Translates a `webdriver://host:port` endpoint into an `http://host:port` base. function toHttpBase(endpointURL: string): string { return endpointURL.replace(/^webdriver:\/\//, 'http://'); } +// A session deleted by a previous test needs a moment before Safari unpairs; +// retry session creation while the driver still reports it as paired. +async function createSession(connection: WDConnection, progress: Progress): Promise { + let lastError: Error | undefined; + for (let i = 0; i < 10; i++) { + try { + return await progress.race(WDSession.create(connection, { alwaysMatch: { browserName: 'safari' } })); + } catch (e) { + lastError = e as Error; + if (!/already paired|session not created/i.test(lastError.message)) + throw lastError; + await progress.race(new Promise(f => setTimeout(f, 500))); + } + } + throw lastError!; +} + function findFreePort(): Promise { return new Promise((resolve, reject) => { const server = net.createServer(); @@ -102,7 +118,7 @@ export async function connectOverWebDriver(progress: Progress, parent: SdkObject await waitForReady(baseURL, progress); const connection = new WDConnection(baseURL, helper.debugProtocolLogger(), browserLogsCollector); - const session = await WDSession.create(connection, { alwaysMatch: { browserName: 'safari' } }); + const session = await createSession(connection, progress); const created = new WDBrowser(parent, connection, session, { slowMo: params.slowMo, diff --git a/packages/playwright-core/src/server/webkit/webdriver/wdConnection.ts b/packages/playwright-core/src/server/webkit/webdriver/wdConnection.ts index e4b5eca998b86..c446726ad6ce5 100644 --- a/packages/playwright-core/src/server/webkit/webdriver/wdConnection.ts +++ b/packages/playwright-core/src/server/webkit/webdriver/wdConnection.ts @@ -23,25 +23,18 @@ export type WDCapabilities = Record; type WDResponse = { value: any }; -/** - * Thin classic W3C WebDriver HTTP client. Unlike the WebKit inspector protocol - * (see ../webview/wvConnection.ts) WebDriver is a synchronous request/response - * protocol with no pushed events, so there is no event dispatch loop here — just - * a `command()` that issues an HTTP request and unwraps the `{ value }` envelope. - */ +// Classic W3C WebDriver HTTP client: synchronous request/response, no events. export class WDConnection { readonly baseURL: string; private readonly _protocolLogger: ProtocolLogger; private readonly _browserLogsCollector: RecentLogsCollector; private _closed = false; - // safaridriver processes one command per session at a time. Playwright can - // issue commands concurrently (races, lifecycle-triggered follow-ups), and - // overlapping requests get reordered by the driver — e.g. a pointerUp landing - // a cycle late, breaking clicks. Serialize every command onto a single chain. + // safaridriver processes one command per session at a time, and overlapping + // requests get reordered by the driver (e.g. a pointerUp landing a cycle late, + // breaking clicks). Serialize every command onto a single chain. private _commandChain: Promise = Promise.resolve(); constructor(baseURL: string, protocolLogger: ProtocolLogger, browserLogsCollector: RecentLogsCollector) { - // Normalize a trailing slash so callers can build `${baseURL}/session/...`. this.baseURL = baseURL.replace(/\/$/, ''); this._protocolLogger = protocolLogger; this._browserLogsCollector = browserLogsCollector; @@ -49,7 +42,6 @@ export class WDConnection { command(httpMethod: 'GET' | 'POST' | 'DELETE', path: string, body?: any): Promise { const result = this._commandChain.then(() => this._sendCommand(httpMethod, path, body)); - // Keep the chain alive regardless of this command's outcome. this._commandChain = result.then(() => {}, () => {}); return result; } @@ -77,8 +69,6 @@ export class WDConnection { throw this._error('error', `${httpMethod} ${path}`, `Non-JSON WebDriver response (${res.status}): ${text.slice(0, 200)}`); } this._protocolLogger('receive', { result: json?.value } as any); - // A WebDriver error is signalled both by a non-2xx status and a `value.error` - // string (https://www.w3.org/TR/webdriver/#errors). const value = json?.value; if (!res.ok || (value && typeof value === 'object' && typeof value.error === 'string')) { const error = (value && value.error) || `HTTP ${res.status}`; @@ -104,10 +94,7 @@ export class WDConnection { } } -/** - * A WebDriver session — wraps a sessionId and exposes the per-session endpoints - * we use. Construct via `WDConnection.createSession`. - */ +// A WebDriver session: a sessionId plus the per-session endpoints we use. export class WDSession { readonly connection: WDConnection; readonly sessionId: string; @@ -119,23 +106,17 @@ export class WDSession { static async create(connection: WDConnection, capabilities: WDCapabilities): Promise { const value = await connection.command('POST', '/session', { capabilities }); - const sessionId = value.sessionId; - if (!sessionId) + if (!value.sessionId) throw new Error('WebDriver did not return a sessionId'); - return new WDSession(connection, sessionId); + return new WDSession(connection, value.sessionId); } send(httpMethod: 'GET' | 'POST' | 'DELETE', command: string, body?: any): Promise { return this.connection.command(httpMethod, `/session/${this.sessionId}/${command}`, body); } - // Runs a script synchronously in the top-level browsing context. - executeSync(script: string, args: any[] = []): Promise { - return this.send('POST', 'execute/sync', { script, args }); - } - - // Runs a script that resolves a callback (the last argument) — lets us await - // promises that `execute/sync` would otherwise drop on the floor. + // Runs a script that resolves a callback (its last argument), so we can await + // promises that `execute/sync` would drop. executeAsync(script: string, args: any[] = []): Promise { return this.send('POST', 'execute/async', { script, args }); } @@ -148,10 +129,6 @@ export class WDSession { return this.send('GET', 'url'); } - title(): Promise { - return this.send('GET', 'title'); - } - windowHandle(): Promise { return this.send('GET', 'window'); } @@ -168,7 +145,7 @@ export class WDSession { return this.send('POST', 'forward'); } - // Returns a base64-encoded PNG of the current viewport. + // Base64-encoded PNG of the current viewport. screenshot(): Promise { return this.send('GET', 'screenshot'); } @@ -189,10 +166,6 @@ export class WDSession { return this.send('POST', 'actions', { actions }); } - releaseActions(): Promise { - return this.send('DELETE', 'actions'); - } - async delete(): Promise { await this.connection.command('DELETE', `/session/${this.sessionId}`).catch(() => {}); } diff --git a/packages/playwright-core/src/server/webkit/webdriver/wdExecutionContext.ts b/packages/playwright-core/src/server/webkit/webdriver/wdExecutionContext.ts index 38ff2b430a012..6978db54a91b0 100644 --- a/packages/playwright-core/src/server/webkit/webdriver/wdExecutionContext.ts +++ b/packages/playwright-core/src/server/webkit/webdriver/wdExecutionContext.ts @@ -20,25 +20,19 @@ import * as dom from '../../dom'; import type { WDSession } from './wdConnection'; -// Metadata we attach to a JSHandle so it can be re-materialized inside a -// WebDriver `execute` call. `registryId` is a key into the page-side -// `window.__pwHandles` map, which is how we emulate live remote handles over a -// protocol that has none. Valid until the document navigates. +// Key into the page-side `window.__pwHandles` map — how we emulate live remote +// handles over a protocol that has none. Valid until the document navigates. type WDHandleMeta = { registryId: string; }; -// Lazily creates the page-side live-handle registry. Idempotent; included at the -// top of every script so `window.__pwHandles.get(...)` is always available. +// Lazily creates the page-side live-handle registry. Idempotent. const kRegistryInit = `window.__pwHandles = window.__pwHandles || new Map(); window.__pwHandleSeq = window.__pwHandleSeq || 0;`; -// Page event captured as a side-channel of every `execute` (see kBridgeInit). export type WDPageEvent = { type: string, text: string }; -// Idempotently installs a console hook that buffers messages on window, so we -// can surface them (and the lifecycle-clearing tag setContent logs) over a -// protocol that pushes no console events. Lives on `window`/`console`, both of -// which survive `document.open()`. +// Idempotently hooks console to buffer messages on `window` (which survives +// `document.open()`), so we can surface them over a protocol with no console events. const kBridgeInit = ` if (!window.__pwBridge) { window.__pwBridge = true; @@ -60,13 +54,10 @@ const kBridgeInit = ` * ExecutionContextDelegate over classic W3C WebDriver `execute/async`. * * WebDriver has no persistent object handles, so we emulate them with a page-side - * registry (`window.__pwHandles`): when an evaluate returns a non-serializable - * value (a DOM node, UtilityScript/InjectedScript, the hit-target interceptor, …) - * we stash it in the registry and return its key. Passing that handle back inlines - * a `window.__pwHandles.get(key)` lookup, so the live object is recovered in-page. - * Crucially this caches UtilityScript/InjectedScript as per-document singletons - * rather than rebuilding them (and re-installing their global event listeners) on - * every evaluate. + * registry (`window.__pwHandles`): a non-serializable evaluate result is stashed + * there and its key returned; passing the handle back inlines a + * `window.__pwHandles.get(key)` lookup. This also keeps UtilityScript/InjectedScript + * as per-document singletons instead of rebuilding them on every evaluate. */ export class WDExecutionContext implements js.ExecutionContextDelegate { private readonly _session: WDSession; @@ -78,8 +69,8 @@ export class WDExecutionContext implements js.ExecutionContextDelegate { this._onPageEvents = onPageEvents; } - // Runs a script, then delivers any buffered console messages and the document - // readyState so the page can synthesize console + lifecycle events. + // Runs a script, then delivers buffered console messages + readyState so the + // page can synthesize console and lifecycle events. private async _execute(script: string, args: any[] = []): Promise { const result = await this._session.executeAsync(script, args); if (result && typeof result === 'object') { @@ -96,9 +87,8 @@ export class WDExecutionContext implements js.ExecutionContextDelegate { } async rawEvaluateHandle(context: js.ExecutionContext, expression: string): Promise { - // The callers are `_utilityScript()` / `injectedScript()`, passing a - // self-contained IIFE source. Evaluate it once and keep the resulting object - // live in the page registry, so it is reused (not rebuilt) on every evaluate. + // Callers pass a self-contained IIFE (UtilityScript/InjectedScript); evaluate + // it once and keep the result live in the registry for reuse. const expr = expression.trim().replace(/;+\s*$/, ''); const value = await this._execute(wrapAsync(` const __pwR = (${expr}); @@ -119,13 +109,11 @@ export class WDExecutionContext implements js.ExecutionContextDelegate { let body: string; if (returnByValue) { - // UtilityScript already returns a JSON-serializable structure. body = `const __pwValues = __pwArgs[0]; return ${call};`; } else { - // Recover a live handle. The InjectedScript polling protocol returns - // `{ result: , abort }` and expects the resolved `.result` to be - // read later; resolve it here (in-page, where the Promise is live) before - // storing, so it survives. Primitives are returned inline. + // The InjectedScript poller returns `{ result: , abort }` and reads + // the resolved `.result` later; resolve it here (where the Promise is live) + // so it survives. Primitives are returned inline, objects via the registry. body = ` const __pwValues = __pwArgs[0]; let __pwR = await ${call}; @@ -184,7 +172,6 @@ export class WDExecutionContext implements js.ExecutionContextDelegate { return false; } - // Builds the JS expression that recovers a handle inside a WebDriver script. private _inlineHandle(handle: js.JSHandle): string { const meta = handle._objectId ? this._handleMeta.get(handle._objectId) : undefined; if (meta?.registryId) @@ -204,11 +191,9 @@ export class WDExecutionContext implements js.ExecutionContextDelegate { } } -// Wraps a body into an `execute/async` script: the last argument is the -// completion callback. Lets the page function return a promise and surfaces -// thrown errors as `{ __pwError }` rather than hanging. Every result also -// carries buffered console events and the document readyState, so the page can -// synthesize console + lifecycle events that WebDriver never pushes. +// Wraps a body into an `execute/async` script (last arg is the completion +// callback): awaits a returned promise, reports thrown errors as `{ __pwError }`, +// and piggybacks buffered console events + readyState onto every result. function wrapAsync(body: string): string { return ` const __pwArgs = arguments; diff --git a/packages/playwright-core/src/server/webkit/webdriver/wdInput.ts b/packages/playwright-core/src/server/webkit/webdriver/wdInput.ts index 4186366a21e9a..de0e6ac94c75f 100644 --- a/packages/playwright-core/src/server/webkit/webdriver/wdInput.ts +++ b/packages/playwright-core/src/server/webkit/webdriver/wdInput.ts @@ -49,14 +49,8 @@ function buttonNumber(button: types.MouseButton): number { return 0; } -/** - * Input over the W3C WebDriver Actions API (`POST /session/{id}/actions`). - * - * WebDriver keeps per-session input state between `performActions` calls, so the - * separate move/down/up delegate calls that Playwright issues accumulate - * correctly. Keyboard modifiers are already pressed as real key actions by - * Playwright's input layer, so the mouse handlers do not re-apply them. - */ +// Input over the W3C WebDriver Actions API. Modifiers are already pressed as +// real key actions by Playwright's input layer, so the mouse handlers ignore them. class RawInput { protected _session: WDSession | undefined; diff --git a/packages/playwright-core/src/server/webkit/webdriver/wdPage.ts b/packages/playwright-core/src/server/webkit/webdriver/wdPage.ts index 9fcae8198e2b1..e1a5ff8062ff4 100644 --- a/packages/playwright-core/src/server/webkit/webdriver/wdPage.ts +++ b/packages/playwright-core/src/server/webkit/webdriver/wdPage.ts @@ -35,12 +35,10 @@ import type { PagePdfParams } from '@protocol/channels'; /** * PageDelegate backed by a classic W3C WebDriver session. * - * WebDriver pushes no events, so this delegate *synthesizes* the frame, - * execution-context and lifecycle events the core Page/Frame machinery expects: - * there is a single (main) frame, a single execution context that is torn down - * and recreated on every navigation, and lifecycle events fired right after a - * blocking WebDriver navigation returns. See ../webview/wvPage.ts for the - * event-driven counterpart. + * WebDriver pushes no events, so this delegate synthesizes the frame, execution + * context and lifecycle events the core expects: a single main frame, one + * execution context recreated on each navigation, and lifecycle events fired + * after a (blocking) WebDriver navigation returns. */ export class WDPage implements PageDelegate { readonly rawMouse: RawMouseImpl; @@ -104,10 +102,9 @@ export class WDPage implements PageDelegate { frame.contextCreated('main', context); } - // WebDriver pushes no console/lifecycle events, so every evaluate piggybacks - // buffered console messages and the document readyState (see wdExecutionContext). - // Delivering the console messages drives the setContent tag handler (which - // clears lifecycle), after which we re-fire lifecycle from readyState. + // Console + lifecycle events synthesized from the side-channel piggybacked on + // every evaluate. Delivering console messages drives the setContent tag handler + // (which clears lifecycle); we then re-fire lifecycle from readyState. private _processPageEvents(events: WDPageEvent[], readyState: string): void { const location = { url: this._mainFrame().url(), lineNumber: 0, columnNumber: 0 }; for (const event of events) @@ -118,9 +115,8 @@ export class WDPage implements PageDelegate { this._page.frameManager.frameLifecycleEvent(this._mainFrameId, 'load'); } - // Synthesizes the commit + context + lifecycle events for a navigation that - // the (blocking) WebDriver command has already completed. Returns the freshly - // generated document id so navigateFrame can satisfy gotoImpl's predicate. + // Synthesizes commit + context + lifecycle events after a (blocking) WebDriver + // navigation completes. The returned documentId must match the one gotoImpl awaits. private async _didNavigate(fallbackUrl: string): Promise { const frame = this._mainFrame(); if (this._context) { diff --git a/tests/webdriver/webdriverTest.ts b/tests/webdriver/webdriverTest.ts index 7762e9505c673..3a075d95cb7bb 100644 --- a/tests/webdriver/webdriverTest.ts +++ b/tests/webdriver/webdriverTest.ts @@ -29,10 +29,8 @@ type WebDriverWorkerFixtures = PageWorkerFixtures & { }; function killStraySafariDrivers(): void { - // Soft-terminate (SIGTERM, not SIGKILL) so a leftover driver ends its session - // and unpairs Safari on the way out. A SIGKILLed driver leaves Safari paired, - // which makes the next session creation fail or pop the "Continue Session" - // dialog. + // SIGTERM (not SIGKILL) so a leftover driver ends its session and unpairs + // Safari; a SIGKILLed driver leaves Safari paired and blocks the next session. spawnSync('pkill', ['-f', 'safaridriver']); } @@ -78,12 +76,7 @@ export const webdriverTest = baseTest.extend { // Clear any driver orphaned by a previous crashed run, giving it a moment // to unpair Safari before we launch a fresh one. @@ -102,6 +95,9 @@ export const webdriverTest = baseTest.extend { const browser = await playwright.webkit.connectOverCDP(webdriverEndpoint); try { From 6287dae418b7198e6979cfe7318e917a74647547 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Thu, 4 Jun 2026 15:19:00 -0700 Subject: [PATCH 05/10] ci: add Safari WebDriver workflow on macOS xlarge Runs the basic page-check/page-goto/page-click suites against Safari over the WebDriver backend on a macos-15-xlarge runner (workflow_dispatch + PR on webdriver paths). Temporarily guards the other PR-triggered workflows with `github.head_ref != 'webdriver-safari-poc'` so they don't fire on the draft PR used to exercise the bot. Revert these TEMP guards before merging. --- .github/workflows/infra.yml | 2 + .github/workflows/tests_bidi.yml | 1 + .github/workflows/tests_components.yml | 1 + .github/workflows/tests_docker_changes.yml | 1 + .github/workflows/tests_extension.yml | 1 + .github/workflows/tests_mcp.yml | 1 + .github/workflows/tests_others.yml | 3 + .github/workflows/tests_primary.yml | 4 ++ .github/workflows/tests_secondary.yml | 8 +++ .github/workflows/tests_webdriver_safari.yml | 64 +++++++++++++++++++ .github/workflows/tests_webview_simulator.yml | 1 + 11 files changed, 87 insertions(+) create mode 100644 .github/workflows/tests_webdriver_safari.yml diff --git a/.github/workflows/infra.yml b/.github/workflows/infra.yml index 8f23797f24d66..e7732fd71fd32 100644 --- a/.github/workflows/infra.yml +++ b/.github/workflows/infra.yml @@ -12,6 +12,7 @@ on: jobs: doc-and-lint: + if: ${{ github.head_ref != 'webdriver-safari-poc' }} # TEMP: skip on webdriver-safari-poc branch name: "docs & lint" runs-on: ubuntu-24.04 steps: @@ -34,6 +35,7 @@ jobs: run: node utils/check_audit.js continue-on-error: true lint-snippets: + if: ${{ github.head_ref != 'webdriver-safari-poc' }} # TEMP: skip on webdriver-safari-poc branch name: "Lint snippets" runs-on: ubuntu-latest steps: diff --git a/.github/workflows/tests_bidi.yml b/.github/workflows/tests_bidi.yml index acd796fb03d4b..f8357c159c24c 100644 --- a/.github/workflows/tests_bidi.yml +++ b/.github/workflows/tests_bidi.yml @@ -23,6 +23,7 @@ env: jobs: test_bidi: + if: ${{ github.head_ref != 'webdriver-safari-poc' }} # TEMP: skip on webdriver-safari-poc branch name: BiDi environment: ${{ github.event_name == 'push' && 'allow-uploading-flakiness-results' || null }} runs-on: ubuntu-24.04 diff --git a/.github/workflows/tests_components.yml b/.github/workflows/tests_components.yml index e525ea0de3bec..88197a4b5d2bb 100644 --- a/.github/workflows/tests_components.yml +++ b/.github/workflows/tests_components.yml @@ -24,6 +24,7 @@ env: jobs: test_components: + if: ${{ github.head_ref != 'webdriver-safari-poc' }} # TEMP: skip on webdriver-safari-poc branch name: ${{ matrix.os }} - Node.js ${{ matrix.node-version }} strategy: fail-fast: false diff --git a/.github/workflows/tests_docker_changes.yml b/.github/workflows/tests_docker_changes.yml index 8807d32ae6788..bcbef18e73776 100644 --- a/.github/workflows/tests_docker_changes.yml +++ b/.github/workflows/tests_docker_changes.yml @@ -17,6 +17,7 @@ on: jobs: test_linux_docker: + if: ${{ github.head_ref != 'webdriver-safari-poc' }} # TEMP: skip on webdriver-safari-poc branch uses: ./.github/workflows/tests_docker.yml permissions: id-token: write diff --git a/.github/workflows/tests_extension.yml b/.github/workflows/tests_extension.yml index c55d28e3dc1fa..4e032acd69968 100644 --- a/.github/workflows/tests_extension.yml +++ b/.github/workflows/tests_extension.yml @@ -34,6 +34,7 @@ env: jobs: test_extension: + if: ${{ github.head_ref != 'webdriver-safari-poc' }} # TEMP: skip on webdriver-safari-poc branch name: ${{ matrix.os }} strategy: fail-fast: false diff --git a/.github/workflows/tests_mcp.yml b/.github/workflows/tests_mcp.yml index 8a5d04390435b..1ee30250efac1 100644 --- a/.github/workflows/tests_mcp.yml +++ b/.github/workflows/tests_mcp.yml @@ -30,6 +30,7 @@ env: jobs: test_mcp: + if: ${{ github.head_ref != 'webdriver-safari-poc' }} # TEMP: skip on webdriver-safari-poc branch name: ${{ matrix.os }} - ${{ matrix.project }} # Used for authentication of flakiness upload environment: ${{ github.event_name == 'push' && 'allow-uploading-flakiness-results' || null }} diff --git a/.github/workflows/tests_others.yml b/.github/workflows/tests_others.yml index 10dc21520056e..44f8ee488f31f 100644 --- a/.github/workflows/tests_others.yml +++ b/.github/workflows/tests_others.yml @@ -21,6 +21,7 @@ env: jobs: test_clock_frozen_time_linux: + if: ${{ github.head_ref != 'webdriver-safari-poc' }} # TEMP: skip on webdriver-safari-poc branch name: time library - ${{ matrix.clock }} environment: ${{ github.event_name == 'push' && 'allow-uploading-flakiness-results' || null }} permissions: @@ -46,6 +47,7 @@ jobs: PW_CLOCK: ${{ matrix.clock }} test_clock_frozen_time_test_runner: + if: ${{ github.head_ref != 'webdriver-safari-poc' }} # TEMP: skip on webdriver-safari-poc branch name: time test runner - ${{ matrix.clock }} environment: ${{ github.event_name == 'push' && 'allow-uploading-flakiness-results' || null }} runs-on: ubuntu-22.04 @@ -70,6 +72,7 @@ jobs: PW_CLOCK: ${{ matrix.clock }} test_electron: + if: ${{ github.head_ref != 'webdriver-safari-poc' }} # TEMP: skip on webdriver-safari-poc branch name: Electron - ${{ matrix.os }} environment: ${{ github.event_name == 'push' && 'allow-uploading-flakiness-results' || null }} strategy: diff --git a/.github/workflows/tests_primary.yml b/.github/workflows/tests_primary.yml index 6dd861c1e2703..a36c8fcd5e8ed 100644 --- a/.github/workflows/tests_primary.yml +++ b/.github/workflows/tests_primary.yml @@ -31,6 +31,7 @@ env: jobs: test_linux: + if: ${{ github.head_ref != 'webdriver-safari-poc' }} # TEMP: skip on webdriver-safari-poc branch name: ${{ matrix.os }} (${{ matrix.browser }} - Node.js ${{ matrix.node-version }}) environment: ${{ github.event_name == 'push' && 'allow-uploading-flakiness-results' || null }} strategy: @@ -66,6 +67,7 @@ jobs: flakiness-subscription-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_SUBSCRIPTION_ID }} test_test_runner: + if: ${{ github.head_ref != 'webdriver-safari-poc' }} # TEMP: skip on webdriver-safari-poc branch name: Test Runner environment: ${{ github.event_name == 'push' && 'allow-uploading-flakiness-results' || null }} strategy: @@ -141,6 +143,7 @@ jobs: PWTEST_SHARD_WEIGHTS: ${{ matrix.shardWeights }} test_vscode_extension: + if: ${{ github.head_ref != 'webdriver-safari-poc' }} # TEMP: skip on webdriver-safari-poc branch name: VSCode Extension runs-on: ubuntu-latest steps: @@ -177,6 +180,7 @@ jobs: job_name: vscode-extension test_package_installations: + if: ${{ github.head_ref != 'webdriver-safari-poc' }} # TEMP: skip on webdriver-safari-poc branch name: "Installation Test ${{ matrix.os }}" environment: ${{ github.event_name == 'push' && 'allow-uploading-flakiness-results' || null }} strategy: diff --git a/.github/workflows/tests_secondary.yml b/.github/workflows/tests_secondary.yml index bf63d6b33700a..db652fffbebc6 100644 --- a/.github/workflows/tests_secondary.yml +++ b/.github/workflows/tests_secondary.yml @@ -21,6 +21,7 @@ permissions: jobs: test_mac: + if: ${{ github.head_ref != 'webdriver-safari-poc' }} # TEMP: skip on webdriver-safari-poc branch name: ${{ matrix.os }} (${{ matrix.browser }}) environment: ${{ github.event_name == 'push' && 'allow-uploading-flakiness-results' || null }} strategy: @@ -46,6 +47,7 @@ jobs: flakiness-subscription-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_SUBSCRIPTION_ID }} test_win: + if: ${{ github.head_ref != 'webdriver-safari-poc' }} # TEMP: skip on webdriver-safari-poc branch name: "Windows" environment: ${{ github.event_name == 'push' && 'allow-uploading-flakiness-results' || null }} strategy: @@ -65,6 +67,7 @@ jobs: flakiness-subscription-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_SUBSCRIPTION_ID }} test-package-installations-other-node-versions: + if: ${{ github.head_ref != 'webdriver-safari-poc' }} # TEMP: skip on webdriver-safari-poc branch name: "Installation Test ${{ matrix.os }} (${{ matrix.node_version }})" environment: ${{ github.event_name == 'push' && 'allow-uploading-flakiness-results' || null }} runs-on: ${{ matrix.os }} @@ -100,6 +103,7 @@ jobs: flakiness-subscription-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_SUBSCRIPTION_ID }} driver_linux: + if: ${{ github.head_ref != 'webdriver-safari-poc' }} # TEMP: skip on webdriver-safari-poc branch name: "Driver" environment: ${{ github.event_name == 'push' && 'allow-uploading-flakiness-results' || null }} strategy: @@ -119,6 +123,7 @@ jobs: PWTEST_MODE: driver tracing_linux: + if: ${{ github.head_ref != 'webdriver-safari-poc' }} # TEMP: skip on webdriver-safari-poc branch name: Tracing ${{ matrix.browser }} environment: ${{ github.event_name == 'push' && 'allow-uploading-flakiness-results' || null }} strategy: @@ -147,6 +152,7 @@ jobs: PWTEST_TRACE: 1 test_chromium_channels: + if: ${{ github.head_ref != 'webdriver-safari-poc' }} # TEMP: skip on webdriver-safari-poc branch name: Test ${{ matrix.channel }} on ${{ matrix.runs-on }} environment: ${{ github.event_name == 'push' && 'allow-uploading-flakiness-results' || null }} runs-on: ${{ matrix.runs-on }} @@ -180,6 +186,7 @@ jobs: PWTEST_CHANNEL: ${{ matrix.channel }} build-playwright-driver: + if: ${{ github.head_ref != 'webdriver-safari-poc' }} # TEMP: skip on webdriver-safari-poc branch name: "build-playwright-driver" runs-on: ubuntu-24.04 steps: @@ -192,6 +199,7 @@ jobs: - run: utils/build/build-playwright-driver.sh test_android: + if: ${{ github.head_ref != 'webdriver-safari-poc' }} # TEMP: skip on webdriver-safari-poc branch name: Android environment: ${{ github.event_name == 'push' && 'allow-uploading-flakiness-results' || null }} runs-on: playwright-x64-ubuntu24-64-core diff --git a/.github/workflows/tests_webdriver_safari.yml b/.github/workflows/tests_webdriver_safari.yml new file mode 100644 index 0000000000000..21b6b08ebaa6e --- /dev/null +++ b/.github/workflows/tests_webdriver_safari.yml @@ -0,0 +1,64 @@ +name: "tests WebDriver (Safari)" + +on: + workflow_dispatch: + pull_request: + paths: + - 'tests/webdriver/**' + - 'tests/page/pageTest.ts' + - 'packages/playwright-core/src/server/webkit/webdriver/**' + - 'packages/playwright-core/src/server/webkit/webkit.ts' + - '.github/workflows/tests_webdriver_safari.yml' + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +env: + FORCE_COLOR: 1 + ELECTRON_SKIP_BINARY_DOWNLOAD: 1 + +jobs: + test_webdriver_safari: + name: "WebDriver on Safari" + runs-on: macos-15-xlarge + timeout-minutes: 60 + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Runner environment + run: | + sw_vers + uname -a + safaridriver --version || true + + - name: Ensure ::1 localhost in /etc/hosts + run: | + grep -qE '^::1[[:space:]]+localhost' /etc/hosts || echo "::1 localhost" | sudo tee -a /etc/hosts + + - name: npm ci + run: npm ci + + - name: npm run build + run: npm run build + + - name: Enable safaridriver + run: sudo safaridriver --enable + + - name: Run WebDriver tests + run: | + npx playwright test --config tests/webdriver/playwright.config.ts \ + tests/page/page-check.spec.ts \ + tests/page/page-goto.spec.ts \ + tests/page/page-click.spec.ts + + - name: Upload artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: webdriver-safari-logs + path: ${{ github.workspace }}/test-results/** + if-no-files-found: ignore diff --git a/.github/workflows/tests_webview_simulator.yml b/.github/workflows/tests_webview_simulator.yml index 610ef3f755289..0b802b7742250 100644 --- a/.github/workflows/tests_webview_simulator.yml +++ b/.github/workflows/tests_webview_simulator.yml @@ -17,6 +17,7 @@ env: jobs: test_webview_simulator: + if: ${{ github.head_ref != 'webdriver-safari-poc' }} # TEMP: skip on webdriver-safari-poc branch name: "WebView on iOS Simulator (${{ matrix.shard }}/4)" runs-on: macos-15 timeout-minutes: 60 From ef09cc37bf23c64ae646d3a3ff7e452820b58aaa Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Thu, 4 Jun 2026 15:33:02 -0700 Subject: [PATCH 06/10] ci(webdriver): cap safaridriver --enable with a 2-minute step timeout --- .github/workflows/tests_webdriver_safari.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/tests_webdriver_safari.yml b/.github/workflows/tests_webdriver_safari.yml index 21b6b08ebaa6e..36eb8c2ce93f2 100644 --- a/.github/workflows/tests_webdriver_safari.yml +++ b/.github/workflows/tests_webdriver_safari.yml @@ -46,6 +46,7 @@ jobs: run: npm run build - name: Enable safaridriver + timeout-minutes: 2 run: sudo safaridriver --enable - name: Run WebDriver tests From 24940950baedb020f19073887f885c075a91917d Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Thu, 4 Jun 2026 15:57:04 -0700 Subject: [PATCH 07/10] ci(webdriver): drop browser name from workflow titles --- .github/workflows/tests_webdriver_safari.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/tests_webdriver_safari.yml b/.github/workflows/tests_webdriver_safari.yml index 36eb8c2ce93f2..24a41be907904 100644 --- a/.github/workflows/tests_webdriver_safari.yml +++ b/.github/workflows/tests_webdriver_safari.yml @@ -1,4 +1,4 @@ -name: "tests WebDriver (Safari)" +name: "tests WebDriver" on: workflow_dispatch: @@ -20,7 +20,7 @@ env: jobs: test_webdriver_safari: - name: "WebDriver on Safari" + name: "WebDriver" runs-on: macos-15-xlarge timeout-minutes: 60 steps: From 23cbf485957235426a6d2165c2a140412fc4fdb2 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Thu, 4 Jun 2026 15:59:06 -0700 Subject: [PATCH 08/10] ci(webdriver): rename workflow/branch refs to drop browser name --- .github/workflows/infra.yml | 4 ++-- .github/workflows/tests_bidi.yml | 2 +- .github/workflows/tests_components.yml | 2 +- .github/workflows/tests_docker_changes.yml | 2 +- .github/workflows/tests_extension.yml | 2 +- .github/workflows/tests_mcp.yml | 2 +- .github/workflows/tests_others.yml | 6 +++--- .github/workflows/tests_primary.yml | 8 ++++---- .github/workflows/tests_secondary.yml | 16 ++++++++-------- ..._webdriver_safari.yml => tests_webdriver.yml} | 6 +++--- .github/workflows/tests_webview_simulator.yml | 2 +- 11 files changed, 26 insertions(+), 26 deletions(-) rename .github/workflows/{tests_webdriver_safari.yml => tests_webdriver.yml} (93%) diff --git a/.github/workflows/infra.yml b/.github/workflows/infra.yml index e7732fd71fd32..b24a6f676c404 100644 --- a/.github/workflows/infra.yml +++ b/.github/workflows/infra.yml @@ -12,7 +12,7 @@ on: jobs: doc-and-lint: - if: ${{ github.head_ref != 'webdriver-safari-poc' }} # TEMP: skip on webdriver-safari-poc branch + if: ${{ github.head_ref != 'webdriver-poc' }} # TEMP: skip on webdriver-poc branch name: "docs & lint" runs-on: ubuntu-24.04 steps: @@ -35,7 +35,7 @@ jobs: run: node utils/check_audit.js continue-on-error: true lint-snippets: - if: ${{ github.head_ref != 'webdriver-safari-poc' }} # TEMP: skip on webdriver-safari-poc branch + if: ${{ github.head_ref != 'webdriver-poc' }} # TEMP: skip on webdriver-poc branch name: "Lint snippets" runs-on: ubuntu-latest steps: diff --git a/.github/workflows/tests_bidi.yml b/.github/workflows/tests_bidi.yml index f8357c159c24c..34c730bfdb995 100644 --- a/.github/workflows/tests_bidi.yml +++ b/.github/workflows/tests_bidi.yml @@ -23,7 +23,7 @@ env: jobs: test_bidi: - if: ${{ github.head_ref != 'webdriver-safari-poc' }} # TEMP: skip on webdriver-safari-poc branch + if: ${{ github.head_ref != 'webdriver-poc' }} # TEMP: skip on webdriver-poc branch name: BiDi environment: ${{ github.event_name == 'push' && 'allow-uploading-flakiness-results' || null }} runs-on: ubuntu-24.04 diff --git a/.github/workflows/tests_components.yml b/.github/workflows/tests_components.yml index 88197a4b5d2bb..60e6774c3d45d 100644 --- a/.github/workflows/tests_components.yml +++ b/.github/workflows/tests_components.yml @@ -24,7 +24,7 @@ env: jobs: test_components: - if: ${{ github.head_ref != 'webdriver-safari-poc' }} # TEMP: skip on webdriver-safari-poc branch + if: ${{ github.head_ref != 'webdriver-poc' }} # TEMP: skip on webdriver-poc branch name: ${{ matrix.os }} - Node.js ${{ matrix.node-version }} strategy: fail-fast: false diff --git a/.github/workflows/tests_docker_changes.yml b/.github/workflows/tests_docker_changes.yml index bcbef18e73776..c6f769dc227ae 100644 --- a/.github/workflows/tests_docker_changes.yml +++ b/.github/workflows/tests_docker_changes.yml @@ -17,7 +17,7 @@ on: jobs: test_linux_docker: - if: ${{ github.head_ref != 'webdriver-safari-poc' }} # TEMP: skip on webdriver-safari-poc branch + if: ${{ github.head_ref != 'webdriver-poc' }} # TEMP: skip on webdriver-poc branch uses: ./.github/workflows/tests_docker.yml permissions: id-token: write diff --git a/.github/workflows/tests_extension.yml b/.github/workflows/tests_extension.yml index 4e032acd69968..b661220520921 100644 --- a/.github/workflows/tests_extension.yml +++ b/.github/workflows/tests_extension.yml @@ -34,7 +34,7 @@ env: jobs: test_extension: - if: ${{ github.head_ref != 'webdriver-safari-poc' }} # TEMP: skip on webdriver-safari-poc branch + if: ${{ github.head_ref != 'webdriver-poc' }} # TEMP: skip on webdriver-poc branch name: ${{ matrix.os }} strategy: fail-fast: false diff --git a/.github/workflows/tests_mcp.yml b/.github/workflows/tests_mcp.yml index 1ee30250efac1..fe594ed488406 100644 --- a/.github/workflows/tests_mcp.yml +++ b/.github/workflows/tests_mcp.yml @@ -30,7 +30,7 @@ env: jobs: test_mcp: - if: ${{ github.head_ref != 'webdriver-safari-poc' }} # TEMP: skip on webdriver-safari-poc branch + if: ${{ github.head_ref != 'webdriver-poc' }} # TEMP: skip on webdriver-poc branch name: ${{ matrix.os }} - ${{ matrix.project }} # Used for authentication of flakiness upload environment: ${{ github.event_name == 'push' && 'allow-uploading-flakiness-results' || null }} diff --git a/.github/workflows/tests_others.yml b/.github/workflows/tests_others.yml index 44f8ee488f31f..a6336cf09ec3a 100644 --- a/.github/workflows/tests_others.yml +++ b/.github/workflows/tests_others.yml @@ -21,7 +21,7 @@ env: jobs: test_clock_frozen_time_linux: - if: ${{ github.head_ref != 'webdriver-safari-poc' }} # TEMP: skip on webdriver-safari-poc branch + if: ${{ github.head_ref != 'webdriver-poc' }} # TEMP: skip on webdriver-poc branch name: time library - ${{ matrix.clock }} environment: ${{ github.event_name == 'push' && 'allow-uploading-flakiness-results' || null }} permissions: @@ -47,7 +47,7 @@ jobs: PW_CLOCK: ${{ matrix.clock }} test_clock_frozen_time_test_runner: - if: ${{ github.head_ref != 'webdriver-safari-poc' }} # TEMP: skip on webdriver-safari-poc branch + if: ${{ github.head_ref != 'webdriver-poc' }} # TEMP: skip on webdriver-poc branch name: time test runner - ${{ matrix.clock }} environment: ${{ github.event_name == 'push' && 'allow-uploading-flakiness-results' || null }} runs-on: ubuntu-22.04 @@ -72,7 +72,7 @@ jobs: PW_CLOCK: ${{ matrix.clock }} test_electron: - if: ${{ github.head_ref != 'webdriver-safari-poc' }} # TEMP: skip on webdriver-safari-poc branch + if: ${{ github.head_ref != 'webdriver-poc' }} # TEMP: skip on webdriver-poc branch name: Electron - ${{ matrix.os }} environment: ${{ github.event_name == 'push' && 'allow-uploading-flakiness-results' || null }} strategy: diff --git a/.github/workflows/tests_primary.yml b/.github/workflows/tests_primary.yml index a36c8fcd5e8ed..cb0b969332f88 100644 --- a/.github/workflows/tests_primary.yml +++ b/.github/workflows/tests_primary.yml @@ -31,7 +31,7 @@ env: jobs: test_linux: - if: ${{ github.head_ref != 'webdriver-safari-poc' }} # TEMP: skip on webdriver-safari-poc branch + if: ${{ github.head_ref != 'webdriver-poc' }} # TEMP: skip on webdriver-poc branch name: ${{ matrix.os }} (${{ matrix.browser }} - Node.js ${{ matrix.node-version }}) environment: ${{ github.event_name == 'push' && 'allow-uploading-flakiness-results' || null }} strategy: @@ -67,7 +67,7 @@ jobs: flakiness-subscription-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_SUBSCRIPTION_ID }} test_test_runner: - if: ${{ github.head_ref != 'webdriver-safari-poc' }} # TEMP: skip on webdriver-safari-poc branch + if: ${{ github.head_ref != 'webdriver-poc' }} # TEMP: skip on webdriver-poc branch name: Test Runner environment: ${{ github.event_name == 'push' && 'allow-uploading-flakiness-results' || null }} strategy: @@ -143,7 +143,7 @@ jobs: PWTEST_SHARD_WEIGHTS: ${{ matrix.shardWeights }} test_vscode_extension: - if: ${{ github.head_ref != 'webdriver-safari-poc' }} # TEMP: skip on webdriver-safari-poc branch + if: ${{ github.head_ref != 'webdriver-poc' }} # TEMP: skip on webdriver-poc branch name: VSCode Extension runs-on: ubuntu-latest steps: @@ -180,7 +180,7 @@ jobs: job_name: vscode-extension test_package_installations: - if: ${{ github.head_ref != 'webdriver-safari-poc' }} # TEMP: skip on webdriver-safari-poc branch + if: ${{ github.head_ref != 'webdriver-poc' }} # TEMP: skip on webdriver-poc branch name: "Installation Test ${{ matrix.os }}" environment: ${{ github.event_name == 'push' && 'allow-uploading-flakiness-results' || null }} strategy: diff --git a/.github/workflows/tests_secondary.yml b/.github/workflows/tests_secondary.yml index db652fffbebc6..3d4ccf121f0ac 100644 --- a/.github/workflows/tests_secondary.yml +++ b/.github/workflows/tests_secondary.yml @@ -21,7 +21,7 @@ permissions: jobs: test_mac: - if: ${{ github.head_ref != 'webdriver-safari-poc' }} # TEMP: skip on webdriver-safari-poc branch + if: ${{ github.head_ref != 'webdriver-poc' }} # TEMP: skip on webdriver-poc branch name: ${{ matrix.os }} (${{ matrix.browser }}) environment: ${{ github.event_name == 'push' && 'allow-uploading-flakiness-results' || null }} strategy: @@ -47,7 +47,7 @@ jobs: flakiness-subscription-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_SUBSCRIPTION_ID }} test_win: - if: ${{ github.head_ref != 'webdriver-safari-poc' }} # TEMP: skip on webdriver-safari-poc branch + if: ${{ github.head_ref != 'webdriver-poc' }} # TEMP: skip on webdriver-poc branch name: "Windows" environment: ${{ github.event_name == 'push' && 'allow-uploading-flakiness-results' || null }} strategy: @@ -67,7 +67,7 @@ jobs: flakiness-subscription-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_SUBSCRIPTION_ID }} test-package-installations-other-node-versions: - if: ${{ github.head_ref != 'webdriver-safari-poc' }} # TEMP: skip on webdriver-safari-poc branch + if: ${{ github.head_ref != 'webdriver-poc' }} # TEMP: skip on webdriver-poc branch name: "Installation Test ${{ matrix.os }} (${{ matrix.node_version }})" environment: ${{ github.event_name == 'push' && 'allow-uploading-flakiness-results' || null }} runs-on: ${{ matrix.os }} @@ -103,7 +103,7 @@ jobs: flakiness-subscription-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_SUBSCRIPTION_ID }} driver_linux: - if: ${{ github.head_ref != 'webdriver-safari-poc' }} # TEMP: skip on webdriver-safari-poc branch + if: ${{ github.head_ref != 'webdriver-poc' }} # TEMP: skip on webdriver-poc branch name: "Driver" environment: ${{ github.event_name == 'push' && 'allow-uploading-flakiness-results' || null }} strategy: @@ -123,7 +123,7 @@ jobs: PWTEST_MODE: driver tracing_linux: - if: ${{ github.head_ref != 'webdriver-safari-poc' }} # TEMP: skip on webdriver-safari-poc branch + if: ${{ github.head_ref != 'webdriver-poc' }} # TEMP: skip on webdriver-poc branch name: Tracing ${{ matrix.browser }} environment: ${{ github.event_name == 'push' && 'allow-uploading-flakiness-results' || null }} strategy: @@ -152,7 +152,7 @@ jobs: PWTEST_TRACE: 1 test_chromium_channels: - if: ${{ github.head_ref != 'webdriver-safari-poc' }} # TEMP: skip on webdriver-safari-poc branch + if: ${{ github.head_ref != 'webdriver-poc' }} # TEMP: skip on webdriver-poc branch name: Test ${{ matrix.channel }} on ${{ matrix.runs-on }} environment: ${{ github.event_name == 'push' && 'allow-uploading-flakiness-results' || null }} runs-on: ${{ matrix.runs-on }} @@ -186,7 +186,7 @@ jobs: PWTEST_CHANNEL: ${{ matrix.channel }} build-playwright-driver: - if: ${{ github.head_ref != 'webdriver-safari-poc' }} # TEMP: skip on webdriver-safari-poc branch + if: ${{ github.head_ref != 'webdriver-poc' }} # TEMP: skip on webdriver-poc branch name: "build-playwright-driver" runs-on: ubuntu-24.04 steps: @@ -199,7 +199,7 @@ jobs: - run: utils/build/build-playwright-driver.sh test_android: - if: ${{ github.head_ref != 'webdriver-safari-poc' }} # TEMP: skip on webdriver-safari-poc branch + if: ${{ github.head_ref != 'webdriver-poc' }} # TEMP: skip on webdriver-poc branch name: Android environment: ${{ github.event_name == 'push' && 'allow-uploading-flakiness-results' || null }} runs-on: playwright-x64-ubuntu24-64-core diff --git a/.github/workflows/tests_webdriver_safari.yml b/.github/workflows/tests_webdriver.yml similarity index 93% rename from .github/workflows/tests_webdriver_safari.yml rename to .github/workflows/tests_webdriver.yml index 24a41be907904..4679c4dfdf34f 100644 --- a/.github/workflows/tests_webdriver_safari.yml +++ b/.github/workflows/tests_webdriver.yml @@ -8,7 +8,7 @@ on: - 'tests/page/pageTest.ts' - 'packages/playwright-core/src/server/webkit/webdriver/**' - 'packages/playwright-core/src/server/webkit/webkit.ts' - - '.github/workflows/tests_webdriver_safari.yml' + - '.github/workflows/tests_webdriver.yml' concurrency: group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} @@ -19,7 +19,7 @@ env: ELECTRON_SKIP_BINARY_DOWNLOAD: 1 jobs: - test_webdriver_safari: + test_webdriver: name: "WebDriver" runs-on: macos-15-xlarge timeout-minutes: 60 @@ -60,6 +60,6 @@ jobs: if: always() uses: actions/upload-artifact@v4 with: - name: webdriver-safari-logs + name: webdriver-logs path: ${{ github.workspace }}/test-results/** if-no-files-found: ignore diff --git a/.github/workflows/tests_webview_simulator.yml b/.github/workflows/tests_webview_simulator.yml index 0b802b7742250..b782c53b14351 100644 --- a/.github/workflows/tests_webview_simulator.yml +++ b/.github/workflows/tests_webview_simulator.yml @@ -17,7 +17,7 @@ env: jobs: test_webview_simulator: - if: ${{ github.head_ref != 'webdriver-safari-poc' }} # TEMP: skip on webdriver-safari-poc branch + if: ${{ github.head_ref != 'webdriver-poc' }} # TEMP: skip on webdriver-poc branch name: "WebView on iOS Simulator (${{ matrix.shard }}/4)" runs-on: macos-15 timeout-minutes: 60 From dce184a5fec905007eb90d1d89759c7f8f2d20f4 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Fri, 12 Jun 2026 13:03:17 -0700 Subject: [PATCH 09/10] feat(webkit): synthesize navigation Response in WebDriver backend page.goto() now resolves to a Response (status read from PerformanceNavigationTiming when the browser exposes it, else 200), fixing the cluster of page-goto tests that crashed on a null response. --- .../src/server/webkit/webdriver/wdPage.ts | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/packages/playwright-core/src/server/webkit/webdriver/wdPage.ts b/packages/playwright-core/src/server/webkit/webdriver/wdPage.ts index e1a5ff8062ff4..74a7ba9ef0075 100644 --- a/packages/playwright-core/src/server/webkit/webdriver/wdPage.ts +++ b/packages/playwright-core/src/server/webkit/webdriver/wdPage.ts @@ -19,6 +19,7 @@ import jpegjs from 'jpeg-js'; import { ManualPromise } from '@isomorphic/manualPromise'; import { createGuid } from '@utils/crypto'; import * as dom from '../../dom'; +import * as network from '../../network'; import { Page } from '../../page'; import { WDExecutionContext } from './wdExecutionContext'; import { RawKeyboardImpl, RawMouseImpl, RawTouchscreenImpl } from './wdInput'; @@ -125,13 +126,32 @@ export class WDPage implements PageDelegate { } const url = await this._session.currentUrl().catch(() => fallbackUrl); const documentId = createGuid(); + // WebDriver exposes no network events; synthesize a main-document + // request/response so page.goto() resolves to a Response. The HTTP status is + // read from PerformanceNavigationTiming once the new document is available. + let request: network.Request | undefined; + if (/^https?:/i.test(url)) { + request = new network.Request(this._page.browserContext, frame, null, null, documentId, url, 'document', 'GET', null, []); + this._page.frameManager.requestStarted(request); + } this._page.frameManager.frameCommittedNewDocumentNavigation(this._mainFrameId, url, '', documentId, false); this._createContext(); + if (request) { + const status = await this._navigationStatus(); + const response = new network.Response(request, status, statusText(status), [], kNoTiming, async () => Buffer.from(''), false); + this._page.frameManager.requestReceivedResponse(response); + this._page.frameManager.reportRequestFinished(request, response); + } this._page.frameManager.frameLifecycleEvent(this._mainFrameId, 'domcontentloaded'); this._page.frameManager.frameLifecycleEvent(this._mainFrameId, 'load'); return { newDocumentId: documentId }; } + private async _navigationStatus(): Promise { + const status = await this._context!.rawEvaluateJSON('(performance.getEntriesByType("navigation")[0] || {}).responseStatus || 0').catch(() => 0); + return status || 200; + } + async navigateFrame(frame: frames.Frame, url: string, referrer: string | undefined): Promise { if (frame.parentFrame()) throw new Error('Only main-frame navigation is supported over WebDriver.'); @@ -299,3 +319,12 @@ export class WDPage implements PageDelegate { throw new Error('Adopting element handles is not supported over WebDriver.'); } } + +const kNoTiming: network.ResourceTiming = { + startTime: 0, domainLookupStart: -1, domainLookupEnd: -1, connectStart: -1, + secureConnectionStart: -1, connectEnd: -1, requestStart: -1, responseStart: -1, +}; + +function statusText(status: number): string { + return status === 200 ? 'OK' : ''; +} From d62324136f9f6d70832cf864a13fa2eb95583a4d Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Fri, 12 Jun 2026 13:15:00 -0700 Subject: [PATCH 10/10] fix(webkit): preserve undefined in WebDriver evaluate results W3C WebDriver's result clone coerces a top-level undefined to null; re-encode it as the serializer's undefined marker so page.evaluate(() => undefined) round-trips as undefined instead of null. --- .../src/server/webkit/webdriver/wdExecutionContext.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/playwright-core/src/server/webkit/webdriver/wdExecutionContext.ts b/packages/playwright-core/src/server/webkit/webdriver/wdExecutionContext.ts index 6978db54a91b0..4ff3126f68658 100644 --- a/packages/playwright-core/src/server/webkit/webdriver/wdExecutionContext.ts +++ b/packages/playwright-core/src/server/webkit/webdriver/wdExecutionContext.ts @@ -109,7 +109,9 @@ export class WDExecutionContext implements js.ExecutionContextDelegate { let body: string; if (returnByValue) { - body = `const __pwValues = __pwArgs[0]; return ${call};`; + // WebDriver's result clone coerces a top-level `undefined` to `null`, so + // re-encode it as the serializer's `undefined` marker to round-trip it. + body = `const __pwValues = __pwArgs[0]; const __pwR = await ${call}; return __pwR === undefined ? { v: 'undefined' } : __pwR;`; } else { // The InjectedScript poller returns `{ result: , abort }` and reads // the resolved `.result` later; resolve it here (where the Promise is live)