diff --git a/.github/workflows/tests_webkit_wsl.yml b/.github/workflows/tests_webkit_wsl.yml new file mode 100644 index 0000000000000..9eab050b07423 --- /dev/null +++ b/.github/workflows/tests_webkit_wsl.yml @@ -0,0 +1,71 @@ +name: "tests webkit wsl" + +on: + push: + branches: + - main + - release-* + pull_request: + paths-ignore: + - 'browser_patches/**' + - 'docs/**' + - 'packages/playwright/src/mcp/**' + - 'tests/mcp/**' + branches: + - main + - release-* + workflow_dispatch: + +env: + # Force terminal colors. @see https://www.npmjs.com/package/colors + FORCE_COLOR: 1 + +permissions: + id-token: write # This is required for OIDC login (azure/login) to succeed + contents: read # This is required for actions/checkout to succeed + +jobs: + test_webkit_wsl: + name: "Tests @ WebKit WSL ${{ matrix.headed && '(headed)' || '(headless)' }}" + # WebKit in WSL needs a Windows host with WSL2 mirrored networking, which the + # GitHub-hosted windows-latest image does not support. Use the self-hosted pool. + runs-on: ["self-hosted", "1ES.Pool=DevDivPlaywrightWindows11"] + strategy: + fail-fast: false + matrix: + headed: [true, false] + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: 20 + # WebKit in WSL only reaches servers on the Windows host with mirrored networking. + - name: Enable WSL2 networkingMode=mirrored + shell: powershell + run: Add-Content -Path $env:USERPROFILE\.wslconfig -Value "[wsl2]`nnetworkingMode=mirrored" + - run: npm ci + env: + DEBUG: pw:install + PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 + - run: npm run build + - run: npx playwright install webkit-wsl + - name: Run tests + run: npm run wtest -- ${{ matrix.headed && '--headed' || '' }} + env: + PWTEST_CHANNEL: webkit-wsl + PW_TAG: "@webkit-wsl-${{ matrix.headed && 'headed' || 'headless' }}" + - name: Azure Login + if: ${{ !cancelled() && github.event_name == 'push' && github.repository == 'microsoft/playwright' }} + uses: azure/login@v2 + with: + client-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_SUBSCRIPTION_ID }} + - run: ./utils/upload_flakiness_dashboard.sh ./test-results/report.json + if: ${{ !cancelled() && github.event_name == 'push' && github.repository == 'microsoft/playwright' }} + shell: bash + - uses: actions/upload-artifact@v7 + if: ${{ !cancelled() }} + with: + name: webkit-wsl-${{ matrix.headed && 'headed' || 'headless' }}-results + path: test-results diff --git a/packages/playwright-core/src/server/browser.ts b/packages/playwright-core/src/server/browser.ts index edbc72a480ea9..0f441fdcb1cec 100644 --- a/packages/playwright-core/src/server/browser.ts +++ b/packages/playwright-core/src/server/browser.ts @@ -199,6 +199,8 @@ export abstract class Browser extends SdkObject { async killForTests(progress: Progress) { await progress.race(this.options.browserProcess.kill()); + if (this.isConnected()) + await progress.race(new Promise(x => this.once(Browser.Events.Disconnected, x))); } } diff --git a/packages/playwright-core/src/server/browserType.ts b/packages/playwright-core/src/server/browserType.ts index 5631142825940..3dd0d51969168 100644 --- a/packages/playwright-core/src/server/browserType.ts +++ b/packages/playwright-core/src/server/browserType.ts @@ -273,7 +273,7 @@ export abstract class BrowserType extends SdkObject { const updatedLog = this.doRewriteStartupLog(log); throw new Error(`Failed to launch the browser process.\nBrowser logs:\n${updatedLog}`); } - if (!this.supportsPipeTransport()) { + if (!this.supportsPipeTransport(options)) { transport = await WebSocketTransport.connect(progress, wsEndpoint!); } else { const stdio = launchedProcess.stdio as unknown as [NodeJS.ReadableStream, NodeJS.WritableStream, NodeJS.WritableStream, NodeJS.WritableStream, NodeJS.ReadableStream]; @@ -337,7 +337,7 @@ export abstract class BrowserType extends SdkObject { async prepareUserDataDir(options: types.LaunchOptions, userDataDir: string): Promise { } - supportsPipeTransport(): boolean { + supportsPipeTransport(options: types.LaunchOptions): boolean { return true; } diff --git a/packages/playwright-core/src/server/registry/index.ts b/packages/playwright-core/src/server/registry/index.ts index d8b1836279398..55b59d722fdd5 100644 --- a/packages/playwright-core/src/server/registry/index.ts +++ b/packages/playwright-core/src/server/registry/index.ts @@ -897,12 +897,19 @@ export class Registry { _dependencyGroup: 'webkit', _isHermeticInstallation: true, }); + const wslExecutable = process.platform === 'win32' ? path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'wsl.exe') : undefined; this._executables.push({ name: 'webkit-wsl', browserName: 'webkit', directory: webkit.dir, - executablePath: () => webkitExecutable, - executablePathOrDie: (sdkLanguage: string) => executablePathOrDie('webkit', webkitExecutable, webkit.installByDefault, sdkLanguage), + executablePath: () => wslExecutable, + executablePathOrDie: () => { + if (!wslExecutable) + throw new Error(`webkit-wsl is only supported on Windows`); + return wslExecutable; + }, + // WebKit is installed inside the WSL distribution by install_webkit_wsl.ps1. + wslExecutablePath: `/home/pwuser/.cache/ms-playwright/webkit-${webkit.revision}/pw_run.sh`, installType: 'download-on-demand', title: 'Webkit in WSL', _validateHostRequirements: (sdkLanguage: string) => Promise.resolve(), diff --git a/packages/playwright-core/src/server/webkit/webkit.ts b/packages/playwright-core/src/server/webkit/webkit.ts index da412abdc192b..1b90404a296dd 100644 --- a/packages/playwright-core/src/server/webkit/webkit.ts +++ b/packages/playwright-core/src/server/webkit/webkit.ts @@ -17,11 +17,13 @@ import path from 'path'; +import { ManualPromise } from '@isomorphic/manualPromise'; import { wrapInASCIIBox } from '@utils/ascii'; import { spawnAsync } from '@utils/spawnAsync'; import { kBrowserCloseMessageId } from './wkConnection'; import { Browser } from '../browser'; import { BrowserType, kNoXServerRunningError } from '../browserType'; +import { registry } from '../registry'; import { WKBrowser } from './wkBrowser'; import { connectOverRDP } from './webview/wvBrowser'; @@ -30,8 +32,14 @@ import type { SdkObject } from '../instrumentation'; import type { Progress } from '../progress'; import type { ConnectionTransport } from '../transport'; import type * as types from '../types'; +import type { RecentLogsCollector } from '@utils/debugLogger'; import type * as channels from '@protocol/channels'; +// Must be kept in sync with bin/install_webkit_wsl.ps1 that provisions the distribution. +const kWSLDistribution = 'playwright'; +const kWSLUser = 'pwuser'; +const kWSLHome = '/home/pwuser'; + export class WebKit extends BrowserType { constructor(parent: SdkObject) { super(parent, 'webkit'); @@ -48,10 +56,27 @@ export class WebKit extends BrowserType { override amendEnvironment(env: NodeJS.ProcessEnv, userDataDir: string, isPersistent: boolean, options: types.LaunchOptions): NodeJS.ProcessEnv { return { ...env, - CURL_COOKIE_JAR_PATH: process.platform === 'win32' && isPersistent ? path.join(userDataDir, 'cookiejar.db') : undefined, + // Cookie jar is only used by the Windows port of WebKit. + CURL_COOKIE_JAR_PATH: process.platform === 'win32' && options.channel !== 'webkit-wsl' && isPersistent ? path.join(userDataDir, 'cookiejar.db') : undefined, }; } + override supportsPipeTransport(options: types.LaunchOptions): boolean { + return options.channel !== 'webkit-wsl'; + } + + override async waitForReadyState(options: types.LaunchOptions, browserLogsCollector: RecentLogsCollector): Promise<{ wsEndpoint?: string }> { + if (options.channel !== 'webkit-wsl') + return {}; + const result = new ManualPromise<{ wsEndpoint?: string }>(); + browserLogsCollector.onMessage(message => { + const match = message.match(/Playwright listening on (ws:\/\/\S+)/); + if (match) + result.resolve({ wsEndpoint: match[1] }); + }); + return result; + } + override doRewriteStartupLog(logs: string): string { if (logs.includes('Failed to open display') || logs.includes('cannot open display')) logs = '\n' + wrapInASCIIBox(kNoXServerRunningError, 1); @@ -70,14 +95,28 @@ export class WebKit extends BrowserType { throw this._createUserDataDirArgMisuseError('--user-data-dir'); if (args.find(arg => !arg.startsWith('-'))) throw new Error('Arguments can not specify page to be opened'); - const webkitArguments = ['--inspector-pipe']; + const isWSL = options.channel === 'webkit-wsl'; + const webkitArguments = [isWSL ? '--remote-debugging-port=0' : '--inspector-pipe']; + + if (isWSL) { + if (options.executablePath) + throw new Error('Cannot specify executablePath when using the "webkit-wsl" channel.'); + // The actual command is `wsl.exe -- `. + webkitArguments.unshift( + '-d', kWSLDistribution, + '-u', kWSLUser, + '--cd', kWSLHome, + '--', + registry.findExecutable('webkit-wsl')!.wslExecutablePath!, + ); + } - if (process.platform === 'win32' && options.channel !== 'webkit-wsl') + if (process.platform === 'win32' && !isWSL) webkitArguments.push('--disable-accelerated-compositing'); if (headless) webkitArguments.push('--headless'); if (isPersistent) - webkitArguments.push(`--user-data-dir=${options.channel === 'webkit-wsl' ? await translatePathToWSL(userDataDir) : userDataDir}`); + webkitArguments.push(`--user-data-dir=${isWSL ? await translatePathToWSL(userDataDir) : userDataDir}`); else webkitArguments.push(`--no-startup-window`); const proxy = options.proxyOverride || options.proxy; @@ -86,7 +125,7 @@ export class WebKit extends BrowserType { webkitArguments.push(`--proxy=${proxy.server}`); if (proxy.bypass) webkitArguments.push(`--proxy-bypass-list=${proxy.bypass}`); - } else if (process.platform === 'linux' || (process.platform === 'win32' && options.channel === 'webkit-wsl')) { + } else if (process.platform === 'linux' || isWSL) { webkitArguments.push(`--proxy=${proxy.server}`); if (proxy.bypass) webkitArguments.push(...proxy.bypass.split(',').map(t => `--ignore-host=${t}`)); @@ -106,6 +145,6 @@ export class WebKit extends BrowserType { } export async function translatePathToWSL(path: string): Promise { - const { stdout } = await spawnAsync('wsl.exe', ['-d', 'playwright', '--cd', '/home/pwuser', 'wslpath', path.replace(/\\/g, '\\\\')]); + const { stdout } = await spawnAsync('wsl.exe', ['-d', kWSLDistribution, '--cd', kWSLHome, 'wslpath', path.replace(/\\/g, '\\\\')]); return stdout.toString().trim(); } diff --git a/tests/library/defaultbrowsercontext-2.spec.ts b/tests/library/defaultbrowsercontext-2.spec.ts index d583cb0b0bf5a..ef8fd830a2c09 100644 --- a/tests/library/defaultbrowsercontext-2.spec.ts +++ b/tests/library/defaultbrowsercontext-2.spec.ts @@ -108,8 +108,9 @@ it('should accept relative userDataDir', async ({ createUserDataDir, browserType await context.close(); }); -it('should restore state from userDataDir', async ({ browserType, server, createUserDataDir }) => { +it('should restore state from userDataDir', async ({ browserType, server, createUserDataDir, channel }) => { it.slow(); + it.fixme(channel === 'webkit-wsl', 'Pending local storage writes are lost on close, see https://github.com/microsoft/playwright-browsers/issues/2275'); const userDataDir = await createUserDataDir(); const browserContext = await browserType.launchPersistentContext(userDataDir); diff --git a/tests/library/har-websocket.spec.ts b/tests/library/har-websocket.spec.ts index f9c90544eb4d1..34d25583240a4 100644 --- a/tests/library/har-websocket.spec.ts +++ b/tests/library/har-websocket.spec.ts @@ -130,7 +130,7 @@ it('should include websocket handshake headers and status', async ({ contextFact expect(responseHeaderNames).toContain('sec-websocket-accept'); }); -async function testWebSocketMessages(contextFactory, server, testInfo, content) { +async function testWebSocketMessages(contextFactory, server, testInfo, content, channel?) { const incomingText = ['x'.repeat(125), 'x'.repeat(126), 'x'.repeat(2 ** 16)]; const incomingBinary = [(new Array(125)).fill(0x01), (new Array(126)).fill(0x01), (new Array(2 ** 16)).fill(0x01)]; const outgoingText = ['y'.repeat(125), 'y'.repeat(126), 'y'.repeat(2 ** 16)]; @@ -198,23 +198,27 @@ async function testWebSocketMessages(contextFactory, server, testInfo, content) ...outgoingText.map(m => ({ type: 'send', opcode: 1, data: m })), ...outgoingBinary.map(m => ({ type: 'send', opcode: 2, data: m })), ]); - for (const m of messages) { - expect(m.time).toBeGreaterThanOrEqual(beforeMs - 1); - expect(m.time).toBeLessThanOrEqual(afterMs + 1); + // The WSL VM clock drifts relative to the Windows host clock, so the browser-reported + // message times cannot be compared against the host wall clock. + if (channel !== 'webkit-wsl') { + for (const m of messages) { + expect(m.time).toBeGreaterThanOrEqual(beforeMs - 1); + expect(m.time).toBeLessThanOrEqual(afterMs + 1); + } } expect(messages[0].time).toBeLessThanOrEqual(messages[1].time); expect(wsEntry.time).toBeGreaterThanOrEqual(messages[messages.length - 1].time - messages[0].time); } -it('should embed websocket messages', async ({ contextFactory, server }, testInfo) => { - await testWebSocketMessages(contextFactory, server, testInfo, 'embed'); +it('should embed websocket messages', async ({ contextFactory, server, channel }, testInfo) => { + await testWebSocketMessages(contextFactory, server, testInfo, 'embed', channel); }); -it('should attach websocket messages', async ({ contextFactory, server }, testInfo) => { - await testWebSocketMessages(contextFactory, server, testInfo, 'attach'); +it('should attach websocket messages', async ({ contextFactory, server, channel }, testInfo) => { + await testWebSocketMessages(contextFactory, server, testInfo, 'attach', channel); }); -it('should attach websocket messages for a still open websocket after stopping', async ({ contextFactory, server }, testInfo) => { +it('should attach websocket messages for a still open websocket after stopping', async ({ contextFactory, server, channel }, testInfo) => { const incomingText = 'incoming'; const incomingBinary = [0x01, 0x02, 0x03, 0x04]; const outgoingText = 'outgoing'; @@ -271,9 +275,13 @@ it('should attach websocket messages for a still open websocket after stopping', { type: 'send', opcode: 2, data: outgoingBinary }, { type: 'receive', opcode: 2, data: incomingBinary }, ]); - for (const m of messages) { - expect(m.time).toBeGreaterThanOrEqual(beforeMs - 1); - expect(m.time).toBeLessThanOrEqual(afterMs + 1); + // The WSL VM clock drifts relative to the Windows host clock, so the browser-reported + // message times cannot be compared against the host wall clock. + if (channel !== 'webkit-wsl') { + for (const m of messages) { + expect(m.time).toBeGreaterThanOrEqual(beforeMs - 1); + expect(m.time).toBeLessThanOrEqual(afterMs + 1); + } } expect(messages[0].time).toBeLessThanOrEqual(messages[1].time); expect(wsEntry.time).toBeGreaterThanOrEqual(messages[messages.length - 1].time - messages[0].time); @@ -283,7 +291,8 @@ it('should omit websocket messages', async ({ contextFactory, server }, testInfo await testWebSocketMessages(contextFactory, server, testInfo, 'omit'); }); -it('should record websocket connection failure', async ({ contextFactory, server }, testInfo) => { +it('should record websocket connection failure', async ({ contextFactory, server, channel }, testInfo) => { + it.skip(channel === 'webkit-wsl', 'Connection to an unbound localhost port from WSL is not refused in mirrored networking mode'); // Reserve a port and immediately release it so the WebSocket connect attempt is refused. const portReservation = net.createServer(); await new Promise(resolve => portReservation.listen(0, '127.0.0.1', () => resolve())); diff --git a/tests/library/har.spec.ts b/tests/library/har.spec.ts index f23d69eadd332..83bac48caf5f7 100644 --- a/tests/library/har.spec.ts +++ b/tests/library/har.spec.ts @@ -624,8 +624,8 @@ it('should have connection details', async ({ contextFactory, server, browserNam expect(securityDetails).toEqual({}); }); -it('should have security details', async ({ contextFactory, httpsServer, browserName, platform, mode, isFrozenWebkit }, testInfo) => { - it.fail(browserName === 'webkit' && platform === 'win32'); +it('should have security details', async ({ contextFactory, httpsServer, browserName, platform, mode, channel, isFrozenWebkit }, testInfo) => { + it.fail(browserName === 'webkit' && platform === 'win32' && channel !== 'webkit-wsl'); it.skip(isFrozenWebkit); const { page, getLog } = await pageWithHar(contextFactory, testInfo); diff --git a/tests/library/playwright.config.ts b/tests/library/playwright.config.ts index a61919e08eb37..077fcbf4a16ee 100644 --- a/tests/library/playwright.config.ts +++ b/tests/library/playwright.config.ts @@ -53,17 +53,6 @@ const reporters = () => { return result; }; -let connectOptions: any; -let webServer: Config['webServer']; - -if (channel === 'webkit-wsl') { - connectOptions = { wsEndpoint: 'ws://localhost:3777/' }; - webServer = { - command: 'set PWTEST_UNDER_TEST=1 && set WSLENV=PWTEST_UNDER_TEST && wsl.exe -d playwright -u pwuser -- bash -lc \'/home/pwuser/node/bin/npx playwright run-server --port=3777\'', - url: 'http://localhost:3777', - }; -} - const config: Config = { testDir, outputDir, @@ -80,10 +69,6 @@ const config: Config { it('should fire illegal character error', { annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/38388' }, -}, async ({ page, server, browserName, isWindows }) => { +}, async ({ page, server, browserName, isWindows, channel }) => { server.setRoute('/error.html', (req, res) => { res.end(` @@ -223,7 +223,7 @@ it('should fire illegal character error', { ]); if (browserName === 'chromium') expect(error.message).toContain('Invalid or unexpected token'); - else if (browserName === 'webkit' && isWindows) + else if (browserName === 'webkit' && isWindows && channel !== 'webkit-wsl') expect(error.message).toContain('No identifiers allowed directly after numeric literal'); else if (browserName === 'webkit') expect(error.message).toContain('Invalid character'); diff --git a/tests/page/page-fill.spec.ts b/tests/page/page-fill.spec.ts index 217a7f395aec9..a59f8cd12e55a 100644 --- a/tests/page/page-fill.spec.ts +++ b/tests/page/page-fill.spec.ts @@ -73,10 +73,10 @@ it('should fill color input', async ({ page }) => { expect(await page.$eval('input', input => input.value)).toBe('#aaaaaa'); }); -it('should fill color input case insensitive', async ({ page, browserName, isWindows }) => { +it('should fill color input case insensitive', async ({ page, browserName, isWindows, channel }) => { await page.setContent(''); await page.fill('input', '#AbCd00'); - if (browserName === 'webkit' && isWindows) + if (browserName === 'webkit' && isWindows && channel !== 'webkit-wsl') expect(await page.$eval('input', input => input.value)).toBe('#AbCd00'); else expect(await page.$eval('input', input => input.value)).toBe('#abcd00'); diff --git a/tests/page/workers.spec.ts b/tests/page/workers.spec.ts index 1544565ef3f35..f7f1b27360649 100644 --- a/tests/page/workers.spec.ts +++ b/tests/page/workers.spec.ts @@ -60,8 +60,9 @@ it('should report console logs', async function({ page }) { expect(page.url()).not.toContain('blob'); }); -it('should have timestamp on worker console messages', async function({ page, isAndroid }) { +it('should have timestamp on worker console messages', async function({ page, isAndroid, channel }) { it.skip(isAndroid, 'there is a time difference between android emulator and host machine'); + it.skip(channel === 'webkit-wsl', 'there is a time difference between WSL VM and host machine'); const before = Date.now() - 1; // Account for the rounding of fractional timestamps. const [message] = await Promise.all([