diff --git a/.github/workflows/infra.yml b/.github/workflows/infra.yml index 8f23797f24d66..b24a6f676c404 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-poc' }} # TEMP: skip on webdriver-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-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 acd796fb03d4b..34c730bfdb995 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-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 e525ea0de3bec..60e6774c3d45d 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-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 8807d32ae6788..c6f769dc227ae 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-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 c55d28e3dc1fa..b661220520921 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-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 8a5d04390435b..fe594ed488406 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-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 10dc21520056e..a6336cf09ec3a 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-poc' }} # TEMP: skip on webdriver-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-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 @@ -70,6 +72,7 @@ jobs: PW_CLOCK: ${{ matrix.clock }} test_electron: + 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 6dd861c1e2703..cb0b969332f88 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-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: @@ -66,6 +67,7 @@ jobs: flakiness-subscription-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_SUBSCRIPTION_ID }} test_test_runner: + 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: @@ -141,6 +143,7 @@ jobs: PWTEST_SHARD_WEIGHTS: ${{ matrix.shardWeights }} test_vscode_extension: + if: ${{ github.head_ref != 'webdriver-poc' }} # TEMP: skip on webdriver-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-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 bf63d6b33700a..3d4ccf121f0ac 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-poc' }} # TEMP: skip on webdriver-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-poc' }} # TEMP: skip on webdriver-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-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 }} @@ -100,6 +103,7 @@ jobs: flakiness-subscription-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_SUBSCRIPTION_ID }} driver_linux: + 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: @@ -119,6 +123,7 @@ jobs: PWTEST_MODE: driver tracing_linux: + 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: @@ -147,6 +152,7 @@ jobs: PWTEST_TRACE: 1 test_chromium_channels: + 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 }} @@ -180,6 +186,7 @@ jobs: PWTEST_CHANNEL: ${{ matrix.channel }} build-playwright-driver: + if: ${{ github.head_ref != 'webdriver-poc' }} # TEMP: skip on webdriver-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-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.yml b/.github/workflows/tests_webdriver.yml new file mode 100644 index 0000000000000..4679c4dfdf34f --- /dev/null +++ b/.github/workflows/tests_webdriver.yml @@ -0,0 +1,65 @@ +name: "tests WebDriver" + +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.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: + name: "WebDriver" + 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 + timeout-minutes: 2 + 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-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..b782c53b14351 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-poc' }} # TEMP: skip on webdriver-poc branch name: "WebView on iOS Simulator (${{ matrix.shard }}/4)" runs-on: macos-15 timeout-minutes: 60 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..9ecc41871eb25 --- /dev/null +++ b/packages/playwright-core/src/server/webkit/webdriver/wdBrowser.ts @@ -0,0 +1,304 @@ +/** + * 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://'; + +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(); + 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 createSession(connection, progress); + + 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..c446726ad6ce5 --- /dev/null +++ b/packages/playwright-core/src/server/webkit/webdriver/wdConnection.ts @@ -0,0 +1,172 @@ +/** + * 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 }; + +// 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, 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) { + this.baseURL = baseURL.replace(/\/$/, ''); + this._protocolLogger = protocolLogger; + this._browserLogsCollector = browserLogsCollector; + } + + command(httpMethod: 'GET' | 'POST' | 'DELETE', path: string, body?: any): Promise { + const result = this._commandChain.then(() => this._sendCommand(httpMethod, path, body)); + 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}`; + 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); + 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: a sessionId plus the per-session endpoints we use. +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 }); + if (!value.sessionId) + throw new Error('WebDriver did not return a 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 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 }); + } + + navigate(url: string): Promise { + return this.send('POST', 'url', { url }); + } + + currentUrl(): Promise { + return this.send('GET', 'url'); + } + + 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'); + } + + // 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 }); + } + + 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..4ff3126f68658 --- /dev/null +++ b/packages/playwright-core/src/server/webkit/webdriver/wdExecutionContext.ts @@ -0,0 +1,212 @@ +/** + * 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'; + +// 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. +const kRegistryInit = `window.__pwHandles = window.__pwHandles || new Map(); window.__pwHandleSeq = window.__pwHandleSeq || 0;`; + +export type WDPageEvent = { type: string, text: string }; + +// 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; + 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`. + * + * WebDriver has no persistent object handles, so we emulate them with a page-side + * 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; + private readonly _onPageEvents: (events: WDPageEvent[], readyState: string) => void; + private readonly _handleMeta = new Map(); + + constructor(session: WDSession, onPageEvents: (events: WDPageEvent[], readyState: string) => void) { + this._session = session; + this._onPageEvents = onPageEvents; + } + + // 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') { + 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 { + return await this._execute(wrapAsync(`return (${expression});`)); + } + + async rawEvaluateHandle(context: js.ExecutionContext, expression: string): Promise { + // 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}); + 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 { + // `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) { + // 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) + // so it survives. Primitives are returned inline, objects via the registry. + 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 value = await this._execute(wrapAsync(body), [values]); + 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 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}`; + 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._execute(wrapAsync(`window.__pwHandles && window.__pwHandles.delete(${JSON.stringify(meta.registryId)}); return undefined;`)).catch(() => {}); + } + + shouldPrependErrorPrefix(): boolean { + return false; + } + + private _inlineHandle(handle: js.JSHandle): string { + const meta = handle._objectId ? this._handleMeta.get(handle._objectId) : undefined; + 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; + 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 (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; + 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, ...__pwDrain() }), + error => __pwDone({ __pwError: (error && error.stack) || String(error), ...__pwDrain() })); + `; +} 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..de0e6ac94c75f --- /dev/null +++ b/packages/playwright-core/src/server/webkit/webdriver/wdInput.ts @@ -0,0 +1,128 @@ +/** + * 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. 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; + + 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..74a7ba9ef0075 --- /dev/null +++ b/packages/playwright-core/src/server/webkit/webdriver/wdPage.ts @@ -0,0 +1,330 @@ +/** + * 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 * as network from '../../network'; +import { Page } from '../../page'; +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'; +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 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; + 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 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); + } + + // 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) + 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 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) { + frame.contextDestroyed(this._context); + this._context = undefined; + } + 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.'); + 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.'); + } +} + +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' : ''; +} 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..3a075d95cb7bb --- /dev/null +++ b/tests/webdriver/webdriverTest.ts @@ -0,0 +1,117 @@ +/** + * 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 { 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 { + // 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']); +} + +function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +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' }], + + // One long-lived safaridriver per worker. Requires macOS with `safaridriver --enable`. + webdriverEndpoint: [async ({}, run) => { + // Clear any driver orphaned by a previous crashed run, giving it a moment + // to unpair Safari before we launch a fresh one. + killStraySafariDrivers(); + await sleep(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 { + // Graceful stop: let safaridriver delete its session and unpair Safari. + proc.kill('SIGTERM'); + await Promise.race([new Promise(r => proc.once('exit', () => r())), sleep(3000)]); + } + }, { scope: 'worker', timeout: 60000 }], + + // One session per worker — creating a session re-prompts Safari's "remotely + // controlled" dialog, so we open it once rather than per test. Reset to + // about:blank between tests for a measure of isolation. + wdBrowser: [async ({ playwright, webdriverEndpoint }, run) => { + const browser = await playwright.webkit.connectOverCDP(webdriverEndpoint); + 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); + }, +});