Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions packages/playwright-core/bin/install_webkit_wsl.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
$ErrorActionPreference = 'Stop'

# WebKit WSL Installation Script
# See webkit-wsl-transport-server.ts for the complete architecture diagram.
# This script sets up a WSL distribution that will be used to run WebKit.

$Distribution = "playwright"
$Username = "pwuser"

$distributions = (wsl --list --quiet) -split "\r?\n"
if ($distributions -contains $Distribution) {
Write-Host "WSL distribution '$Distribution' already exists. Skipping installation."
} else {
Write-Host "Installing new WSL distribution '$Distribution'..."
$VhdSize = "10GB"
wsl --install -d Ubuntu-24.04 --name $Distribution --no-launch --vhd-size $VhdSize
wsl -d $Distribution -u root adduser --gecos GECOS --disabled-password $Username
}

$pwshDirname = (Resolve-Path -Path $PSScriptRoot).Path;
$playwrightCoreRoot = Resolve-Path (Join-Path $pwshDirname "..")

$initScript = @"
if [ ! -f "/home/$Username/node/bin/node" ]; then
mkdir -p /home/$Username/node
curl -fsSL https://nodejs.org/dist/v22.17.0/node-v22.17.0-linux-x64.tar.xz -o /home/$Username/node/node-v22.17.0-linux-x64.tar.xz
tar -xJf /home/$Username/node/node-v22.17.0-linux-x64.tar.xz -C /home/$Username/node --strip-components=1
fi
/home/$Username/node/bin/node cli.js install-deps webkit
cp lib/server/webkit/wsl/webkit-wsl-transport-client.js /home/$Username/
sudo -u $Username PLAYWRIGHT_SKIP_BROWSER_GC=1 /home/$Username/node/bin/node cli.js install webkit
"@ -replace "\r\n", "`n"

wsl -d $Distribution --cd $playwrightCoreRoot -u root -- bash -c "$initScript"
Write-Host "Done!"
2 changes: 1 addition & 1 deletion packages/playwright-core/src/server/bidi/bidiChromium.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ export class BidiChromium extends BrowserType {
return false;
}

override defaultArgs(options: types.LaunchOptions, isPersistent: boolean, userDataDir: string): string[] {
override async defaultArgs(options: types.LaunchOptions, isPersistent: boolean, userDataDir: string) {
const chromeArguments = this._innerDefaultArgs(options);
chromeArguments.push(`--user-data-dir=${userDataDir}`);
chromeArguments.push('--remote-debugging-port=0');
Expand Down
2 changes: 1 addition & 1 deletion packages/playwright-core/src/server/bidi/bidiFirefox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ export class BidiFirefox extends BrowserType {
});
}

override defaultArgs(options: types.LaunchOptions, isPersistent: boolean, userDataDir: string): string[] {
override async defaultArgs(options: types.LaunchOptions, isPersistent: boolean, userDataDir: string) {
const { args = [], headless } = options;
const userDataDirArg = args.find(arg => arg.startsWith('-profile') || arg.startsWith('--profile'));
if (userDataDirArg)
Expand Down
10 changes: 5 additions & 5 deletions packages/playwright-core/src/server/browserType.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,9 +174,9 @@ export abstract class BrowserType extends SdkObject {
if (ignoreAllDefaultArgs)
browserArguments.push(...args);
else if (ignoreDefaultArgs)
browserArguments.push(...this.defaultArgs(options, isPersistent, userDataDir).filter(arg => ignoreDefaultArgs.indexOf(arg) === -1));
browserArguments.push(...(await this.defaultArgs(options, isPersistent, userDataDir)).filter(arg => ignoreDefaultArgs.indexOf(arg) === -1));
else
browserArguments.push(...this.defaultArgs(options, isPersistent, userDataDir));
browserArguments.push(...await this.defaultArgs(options, isPersistent, userDataDir));

let executable: string;
if (executablePath) {
Expand Down Expand Up @@ -212,7 +212,7 @@ export abstract class BrowserType extends SdkObject {
const { launchedProcess, gracefullyClose, kill } = await launchProcess({
command: prepared.executable,
args: prepared.browserArguments,
env: this.amendEnvironment(env, prepared.userDataDir, isPersistent),
env: this.amendEnvironment(env, prepared.userDataDir, isPersistent, options),
handleSIGINT,
handleSIGTERM,
handleSIGHUP,
Expand Down Expand Up @@ -338,9 +338,9 @@ export abstract class BrowserType extends SdkObject {
return options.channel || this._name;
}

abstract defaultArgs(options: types.LaunchOptions, isPersistent: boolean, userDataDir: string): string[];
abstract defaultArgs(options: types.LaunchOptions, isPersistent: boolean, userDataDir: string): Promise<string[]>;
abstract connectToTransport(transport: ConnectionTransport, options: BrowserOptions, browserLogsCollector: RecentLogsCollector): Promise<Browser>;
abstract amendEnvironment(env: NodeJS.ProcessEnv, userDataDir: string, isPersistent: boolean): NodeJS.ProcessEnv;
abstract amendEnvironment(env: NodeJS.ProcessEnv, userDataDir: string, isPersistent: boolean, options: types.LaunchOptions): NodeJS.ProcessEnv;
abstract doRewriteStartupLog(error: ProtocolError): ProtocolError;
abstract attemptToGracefullyCloseBrowser(transport: ConnectionTransport): void;
}
Expand Down
2 changes: 1 addition & 1 deletion packages/playwright-core/src/server/chromium/chromium.ts
Original file line number Diff line number Diff line change
Expand Up @@ -280,7 +280,7 @@ export class Chromium extends BrowserType {
}
}

override defaultArgs(options: types.LaunchOptions, isPersistent: boolean, userDataDir: string): string[] {
override async defaultArgs(options: types.LaunchOptions, isPersistent: boolean, userDataDir: string) {
const chromeArguments = this._innerDefaultArgs(options);
chromeArguments.push(`--user-data-dir=${userDataDir}`);
if (options.cdpPort !== undefined)
Expand Down
2 changes: 1 addition & 1 deletion packages/playwright-core/src/server/firefox/firefox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ export class Firefox extends BrowserType {
transport.send(message);
}

override defaultArgs(options: types.LaunchOptions, isPersistent: boolean, userDataDir: string): string[] {
override async defaultArgs(options: types.LaunchOptions, isPersistent: boolean, userDataDir: string) {
const { args = [], headless } = options;
const userDataDirArg = args.find(arg => arg.startsWith('-profile') || arg.startsWith('--profile'));
if (userDataDirArg)
Expand Down
28 changes: 27 additions & 1 deletion packages/playwright-core/src/server/registry/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -510,7 +510,7 @@ const allDownloadable = ['android', 'chromium', 'firefox', 'webkit', 'ffmpeg', '

export interface Executable {
type: 'browser' | 'tool' | 'channel';
name: BrowserName | InternalTool | ChromiumChannel | BidiChannel;
name: BrowserName | InternalTool | ChromiumChannel | BidiChannel | 'webkit-wsl';
browserName: BrowserName | undefined;
installType: 'download-by-default' | 'download-on-demand' | 'install-script' | 'none';
directory: string | undefined;
Expand All @@ -519,6 +519,7 @@ export interface Executable {
executablePathOrDie(sdkLanguage: string): string;
executablePath(sdkLanguage: string): string | undefined;
_validateHostRequirements(sdkLanguage: string): Promise<void>;
wslExecutablePath?: string
}

interface ExecutableImpl extends Executable {
Expand Down Expand Up @@ -816,6 +817,31 @@ export class Registry {
_dependencyGroup: 'webkit',
_isHermeticInstallation: true,
});
this._executables.push({
type: 'channel',
name: 'webkit-wsl',
browserName: 'webkit',
directory: webkit.dir,
executablePath: () => process.execPath,
executablePathOrDie: () => process.execPath,
wslExecutablePath: `/home/pwuser/.cache/ms-playwright/webkit-${webkit.revision}/pw_run.sh`,
installType: 'download-on-demand',
_validateHostRequirements: (sdkLanguage: string) => Promise.resolve(),
_isHermeticInstallation: true,
_install: async () => {
if (process.platform !== 'win32')
throw new Error(`WebKit via WSL is only supported on Windows`);
const script = path.join(BIN_PATH, 'install_webkit_wsl.ps1');
const { code } = await spawnAsync('powershell.exe', [
'-ExecutionPolicy', 'Bypass',
'-File', script,
], {
stdio: 'inherit',
});
if (code !== 0)
throw new Error(`Failed to install WebKit via WSL`);
},
});

const ffmpeg = descriptors.find(d => d.name === 'ffmpeg')!;
const ffmpegExecutable = findExecutablePath(ffmpeg.dir, 'ffmpeg');
Expand Down
27 changes: 22 additions & 5 deletions packages/playwright-core/src/server/webkit/webkit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ import { kBrowserCloseMessageId } from './wkConnection';
import { wrapInASCIIBox } from '../utils/ascii';
import { BrowserType, kNoXServerRunningError } from '../browserType';
import { WKBrowser } from '../webkit/wkBrowser';
import { spawnAsync } from '../utils/spawnAsync';
import { registry } from '../registry';

import type { BrowserOptions } from '../browser';
import type { SdkObject } from '../instrumentation';
Expand All @@ -37,10 +39,11 @@ export class WebKit extends BrowserType {
return WKBrowser.connect(this.attribution.playwright, transport, options);
}

override amendEnvironment(env: NodeJS.ProcessEnv, userDataDir: string, isPersistent: boolean): NodeJS.ProcessEnv {
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,
WEBKIT_EXECUTABLE: options.channel === 'webkit-wsl' ? registry.findExecutable('webkit-wsl')!.wslExecutablePath! : undefined
};
}

Expand All @@ -57,20 +60,29 @@ export class WebKit extends BrowserType {
transport.send({ method: 'Playwright.close', params: {}, id: kBrowserCloseMessageId });
}

override defaultArgs(options: types.LaunchOptions, isPersistent: boolean, userDataDir: string): string[] {
override async defaultArgs(options: types.LaunchOptions, isPersistent: boolean, userDataDir: string): Promise<string[]> {
const { args = [], headless } = options;
const userDataDirArg = args.find(arg => arg.startsWith('--user-data-dir'));
if (userDataDirArg)
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'];
if (process.platform === 'win32')

if (options.channel === 'webkit-wsl') {
if (options.executablePath)
throw new Error('Cannot specify executablePath when using the "webkit-wsl" channel.');
webkitArguments.unshift(
path.join(__dirname, 'wsl/webkit-wsl-transport-server.js'),
);
}

if (process.platform === 'win32' && options.channel !== 'webkit-wsl')
webkitArguments.push('--disable-accelerated-compositing');
if (headless)
webkitArguments.push('--headless');
if (isPersistent)
webkitArguments.push(`--user-data-dir=${userDataDir}`);
webkitArguments.push(`--user-data-dir=${options.channel === 'webkit-wsl' ? await translatePathToWSL(userDataDir) : userDataDir}`);
else
webkitArguments.push(`--no-startup-window`);
const proxy = options.proxyOverride || options.proxy;
Expand All @@ -79,7 +91,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') {
} else if (process.platform === 'linux' || (process.platform === 'win32' && options.channel === 'webkit-wsl')) {
webkitArguments.push(`--proxy=${proxy.server}`);
if (proxy.bypass)
webkitArguments.push(...proxy.bypass.split(',').map(t => `--ignore-host=${t}`));
Expand All @@ -97,3 +109,8 @@ export class WebKit extends BrowserType {
return webkitArguments;
}
}

export async function translatePathToWSL(path: string): Promise<string> {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As discussed before, perhaps do this once, save somewhere on WKBrowser and then just path.join() when needed?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The input path is from the user, could be from a different drive, network file share, be a symlink etc. so I don't think any custom path join logic would be bug-free looking at their impl - lets keep the async call for now and iterate later on it if needed?

const { stdout } = await spawnAsync('wsl.exe', ['-d', 'playwright', '--cd', '/home/pwuser', 'wslpath', path.replace(/\\/g, '\\\\')]);
return stdout.toString().trim();
}
5 changes: 3 additions & 2 deletions packages/playwright-core/src/server/webkit/wkBrowser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import * as network from '../network';
import { WKConnection, WKSession, kPageProxyMessageReceived } from './wkConnection';
import { WKPage } from './wkPage';
import { TargetClosedError } from '../errors';
import { translatePathToWSL } from './webkit';

import type { BrowserOptions } from '../browser';
import type { SdkObject } from '../instrumentation';
Expand Down Expand Up @@ -87,7 +88,7 @@ export class WKBrowser extends Browser {
const createOptions = proxy ? {
// Enable socks5 hostname resolution on Windows.
// See https://github.com/microsoft/playwright/issues/20451
proxyServer: process.platform === 'win32' ? proxy.server.replace(/^socks5:\/\//, 'socks5h://') : proxy.server,
proxyServer: process.platform === 'win32' && this.attribution.browser?.options.channel !== 'webkit-wsl' ? proxy.server.replace(/^socks5:\/\//, 'socks5h://') : proxy.server,
proxyBypassList: proxy.bypass
} : undefined;
const { browserContextId } = await this._browserSession.send('Playwright.createContext', createOptions);
Expand Down Expand Up @@ -227,7 +228,7 @@ export class WKBrowserContext extends BrowserContext {
const promises: Promise<any>[] = [super._initialize()];
promises.push(this._browser._browserSession.send('Playwright.setDownloadBehavior', {
behavior: this._options.acceptDownloads === 'accept' ? 'allow' : 'deny',
downloadPath: this._browser.options.downloadsPath,
downloadPath: this._browser.options.channel === 'webkit-wsl' ? await translatePathToWSL(this._browser.options.downloadsPath) : this._browser.options.downloadsPath,
browserContextId
}));
if (this._options.ignoreHTTPSErrors || this._options.internalIgnoreHTTPSErrors)
Expand Down
5 changes: 4 additions & 1 deletion packages/playwright-core/src/server/webkit/wkPage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import { WKInterceptableRequest, WKRouteImpl } from './wkInterceptableRequest';
import { WKProvisionalPage } from './wkProvisionalPage';
import { WKWorkers } from './wkWorkers';
import { debugLogger } from '../utils/debugLogger';
import { translatePathToWSL } from './webkit';

import type { Protocol } from './protocol';
import type { WKBrowserContext } from './wkBrowser';
Expand Down Expand Up @@ -842,7 +843,7 @@ export class WKPage implements PageDelegate {
private async _startVideo(options: types.PageScreencastOptions): Promise<void> {
assert(!this._recordingVideoFile);
const { screencastId } = await this._pageProxySession.send('Screencast.startVideo', {
file: options.outputFile,
file: this._browserContext._browser.options.channel === 'webkit-wsl' ? await translatePathToWSL(options.outputFile) : options.outputFile,
width: options.width,
height: options.height,
toolbarHeight: this._toolbarHeight()
Expand Down Expand Up @@ -976,6 +977,8 @@ export class WKPage implements PageDelegate {
async setInputFilePaths(handle: dom.ElementHandle<HTMLInputElement>, paths: string[]): Promise<void> {
const pageProxyId = this._pageProxySession.sessionId;
const objectId = handle._objectId;
if (this._browserContext._browser?.options.channel === 'webkit-wsl')
paths = await Promise.all(paths.map(path => translatePathToWSL(path)));
await Promise.all([
this._pageProxySession.connection.browserSession.send('Playwright.grantFileReadAccess', { pageProxyId, paths }),
this._session.send('DOM.setInputFiles', { objectId, paths })
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
/**
* 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.
*/
// @ts-check
/* eslint-disable no-restricted-properties */
/* eslint-disable no-console */

// WebKit WSL Transport Client - runs inside WSL/Linux
// See webkit-wsl-transport-server.ts for the complete architecture diagram.
// This client connects to the TCP server and bridges it to WebKit via fd3/fd4 pipes.

import net from 'net';
import fs from 'fs';
import { spawn, spawnSync } from 'child_process';

(async () => {
const { PW_WSL_BRIDGE_PORT: socketPort, ...childEnv } = process.env;
if (!socketPort)
throw new Error('PW_WSL_BRIDGE_PORT env var is not set');

const [executable, ...args] = process.argv.slice(2);

if (!(await fs.promises.stat(executable)).isFile())
throw new Error(`Executable does not exist. Did you update Playwright recently? Make sure to run npx playwright install webkit-wsl`);

const address = (() => {
const res = spawnSync('/usr/bin/wslinfo', ['--networking-mode'], { encoding: 'utf8' });
if (res.error || res.status !== 0)
throw new Error(`Failed to run /usr/bin/wslinfo --networking-mode: ${res.error?.message || res.stderr || res.status}`);
if (res.stdout.trim() === 'nat') {
const ipRes = spawnSync('/usr/sbin/ip', ['route', 'show'], { encoding: 'utf8' });
if (ipRes.error || ipRes.status !== 0)
throw new Error(`Failed to run ip route show: ${ipRes.error?.message || ipRes.stderr || ipRes.status}`);
const ip = ipRes.stdout.trim().split('\n').find(line => line.includes('default'))?.split(' ')[2];
if (!ip)
throw new Error('Could not determine WSL IP address (NAT mode).');
return ip;
}
return '127.0.0.1';
})();

const socket = net.createConnection(parseInt(socketPort, 10), address);
// Disable Nagle's algorithm to reduce latency for small, frequent messages.
socket.setNoDelay(true);

await new Promise((resolve, reject) => {
socket.on('connect', resolve);
socket.on('error', reject);
});

const child = spawn(executable, args, {
stdio: ['inherit', 'inherit', 'inherit', 'pipe', 'pipe'],
env: childEnv,
});

const [childOutput, childInput] = [child.stdio[3] as NodeJS.WritableStream, child.stdio[4] as NodeJS.ReadableStream];
socket.pipe(childOutput);
childInput.pipe(socket);

socket.on('end', () => child.kill());

child.on('exit', exitCode => {
socket.end();
process.exit(exitCode || 0);
});

await new Promise((resolve, reject) => {
child.on('exit', resolve);
child.on('error', reject);
});
})().catch(error => {
console.error('Error occurred:', error);
process.exit(1);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we try to preserve the childs error code?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The child's exit code is already preserved on lines 67-70. The process.exit(1) in the catch block is only for setup errors that happen before the child process starts (like missing env vars, file not found, socket connection issues). Those aren't child exit codes since the child hasn't run yet.

});
Loading
Loading