feat(containers): implement global network tethering for playwright server (#17719)

This patch implements a new mode of network tethering for Playwright
server & its clients.
With this patch:
- playwright server could be launched with the
`--browser-proxy-mode=tether` flag to engage in the new mode
- a new type of client, "Network Tethering Client" can connect to the
server to provide network traffic to the browsers
- all clients that connect to the server with the `x-playwright-proxy:
*` header will get traffic from the "Network Tethering Client"

This patch also adds an environment variable
`PW_OWNED_BY_TETHER_CLIENT`. With this env, playwright server will
auto-close when the network tethering client disconnects. It will also
auto-close if the network client does not connect to the server in the
first 10 seconds of the server existence. This way we can ensure that
`npx playwright docker start` blocks terminal & controls the lifetime of
the started container.
This commit is contained in:
Andrey Lushnikov 2022-11-03 13:47:51 -07:00 committed by GitHub
parent 91f7e3963d
commit 8538f61a72
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 552 additions and 356 deletions

View file

@ -147,6 +147,7 @@ jobs:
- run: |
./utils/docker/build.sh --amd64 focal $PWTEST_DOCKER_BASE_IMAGE
npx playwright docker build
nohup npx playwright docker start &
xvfb-run npm run test-html-reporter
env:
PLAYWRIGHT_DOCKER: 1

View file

@ -67,20 +67,15 @@ git apply clip.patch
# Configure FluxBox menus
mkdir /root/.fluxbox
cd /ms-playwright-agent
cat <<'EOF' | node > /root/.fluxbox/menu
const { chromium, firefox, webkit } = require('playwright-core');
console.log(`
cat <<'EOF' > /root/.fluxbox/menu
[begin] (fluxbox)
[submenu] (Browsers) {}
[exec] (Chromium) { ${chromium.executablePath()} --no-sandbox --test-type= } <>
[exec] (Firefox) { ${firefox.executablePath()} } <>
[exec] (WebKit) { ${webkit.executablePath()} } <>
[exec] (Chromium) { /ms-playwright-agent/node_modules/.bin/playwright docker launch --endpoint http://127.0.0.1:5400 --browser chromium } <>
[exec] (Firefox) { /ms-playwright-agent/node_modules/.bin/playwright docker launch --endpoint http://127.0.0.1:5400 --browser firefox } <>
[exec] (WebKit) { /ms-playwright-agent/node_modules/.bin/playwright docker launch --endpoint http://127.0.0.1:5400 --browser webkit } <>
[end]
[include] (/etc/X11/fluxbox/fluxbox-menu)
[end]
`);
EOF
cat <<'EOF' > /root/.fluxbox/lastwallpaper

View file

@ -28,4 +28,12 @@ NOVNC_UUID=$(cat /proc/sys/kernel/random/uuid)
echo "novnc is listening on http://127.0.0.1:7900?path=$NOVNC_UUID&resize=scale&autoconnect=1"
PW_UUID=$(cat /proc/sys/kernel/random/uuid)
npx playwright run-server --port=5400 --path=/$PW_UUID
# Make sure to re-start playwright server if something goes wrong.
# The approach taken from: https://stackoverflow.com/a/697064/314883
until npx playwright run-server --port=5400 --path=/$PW_UUID --proxy-mode=tether; do
echo "Server crashed with exit code $?. Respawning.." >&2
sleep 1
done

View file

@ -49,7 +49,7 @@ export class AndroidServerLauncherImpl {
const path = options.wsPath ? (options.wsPath.startsWith('/') ? options.wsPath : `/${options.wsPath}`) : `/${createGuid()}`;
// 2. Start the server
const server = new PlaywrightServer({ path, maxConnections: 1, enableSocksProxy: false, preLaunchedAndroidDevice: device });
const server = new PlaywrightServer({ path, maxConnections: 1, preLaunchedAndroidDevice: device, browserProxyMode: 'disabled' });
const wsEndpoint = await server.listen(options.port);
// 3. Return the BrowserServer interface

View file

@ -52,7 +52,7 @@ export class BrowserServerLauncherImpl implements BrowserServerLauncher {
const path = options.wsPath ? (options.wsPath.startsWith('/') ? options.wsPath : `/${options.wsPath}`) : `/${createGuid()}`;
// 2. Start the server
const server = new PlaywrightServer({ path, maxConnections: Infinity, enableSocksProxy: false, preLaunchedBrowser: browser });
const server = new PlaywrightServer({ path, maxConnections: Infinity, browserProxyMode: 'disabled', preLaunchedBrowser: browser });
const wsEndpoint = await server.listen(options.port);
// 3. Return the BrowserServer interface

View file

@ -270,9 +270,15 @@ program
.option('--port <port>', 'Server port')
.option('--path <path>', 'Endpoint Path', '/')
.option('--max-clients <maxClients>', 'Maximum clients')
.option('--no-socks-proxy', 'Disable Socks Proxy')
.option('--proxy-mode <mode>', 'Either `client`, `tether` or `disabled`. Defaults to `client`.', 'client')
.action(function(options) {
runServer(options.port ? +options.port : undefined, options.path, options.maxClients ? +options.maxClients : Infinity, options.socksProxy).catch(logErrorAndExit);
runServer({
port: options.port ? +options.port : undefined,
path: options.path,
maxConnections: options.maxClients ? +options.maxClients : Infinity,
browserProxyMode: options.proxyMode,
ownedByTetherClient: !!process.env.PW_OWNED_BY_TETHER_CLIENT,
}).catch(logErrorAndExit);
});
program

View file

@ -46,8 +46,23 @@ export function runDriver() {
};
}
export async function runServer(port: number | undefined, path = '/', maxConnections = Infinity, enableSocksProxy = true) {
const server = new PlaywrightServer({ path, maxConnections, enableSocksProxy });
export type RunServerOptions = {
port?: number,
path?: string,
maxConnections?: number,
browserProxyMode?: 'client' | 'tether' | 'disabled',
ownedByTetherClient?: boolean,
};
export async function runServer(options: RunServerOptions) {
const {
port,
path = '/',
maxConnections = Infinity,
browserProxyMode = 'client',
ownedByTetherClient = false,
} = options;
const server = new PlaywrightServer({ path, maxConnections, browserProxyMode, ownedByTetherClient });
const wsEndpoint = await server.listen(port);
process.on('exit', () => server.close().catch(console.error));
console.log('Listening on ' + wsEndpoint); // eslint-disable-line no-console

View file

@ -16,7 +16,6 @@
import type * as channels from '@protocol/channels';
import { TimeoutError } from '../common/errors';
import type * as socks from '../common/socksProxy';
import { Android } from './android';
import { BrowserType } from './browserType';
import { ChannelOwner } from './channelOwner';
@ -45,7 +44,6 @@ export class Playwright extends ChannelOwner<channels.PlaywrightChannel> {
selectors: Selectors;
readonly request: APIRequest;
readonly errors: { TimeoutError: typeof TimeoutError };
private _socksProxyHandler: socks.SocksProxyHandler | undefined;
constructor(parent: ChannelOwner, type: string, guid: string, initializer: channels.PlaywrightInitializer) {
super(parent, type, guid, initializer);
@ -68,7 +66,6 @@ export class Playwright extends ChannelOwner<channels.PlaywrightChannel> {
this.selectors._addChannel(selectorsOwner);
this._connection.on('close', () => {
this.selectors._removeChannel(selectorsOwner);
this._socksProxyHandler?.cleanup();
});
(global as any)._playwrightInstance = this;
}

View file

@ -296,6 +296,7 @@ export class SocksProxy extends EventEmitter implements SocksConnectionClient {
private _connections = new Map<string, SocksConnection>();
private _sockets = new Set<net.Socket>();
private _closed = false;
private _port: number | undefined;
constructor() {
super();
@ -314,10 +315,15 @@ export class SocksProxy extends EventEmitter implements SocksConnectionClient {
});
}
port() {
return this._port;
}
async listen(port: number): Promise<number> {
return new Promise(f => {
this._server.listen(port, () => {
const port = (this._server.address() as AddressInfo).port;
this._port = port;
debugLogger.log('proxy', `Starting socks proxy server on port ${port}`);
f(port);
});

View file

@ -2,3 +2,6 @@
../utils/
../utilsBundle.ts
../common/
../server/
../server/dispatchers/
../..

View file

@ -18,9 +18,13 @@
import path from 'path';
import { spawnAsync } from '../utils/spawnAsync';
import * as utils from '../utils';
import { getPlaywrightVersion } from '../common/userAgent';
import { getPlaywrightVersion, getUserAgent } from '../common/userAgent';
import { urlToWSEndpoint } from '../server/dispatchers/localUtilsDispatcher';
import { WebSocketTransport } from '../server/transport';
import { SocksInterceptor } from '../server/socksInterceptor';
import * as dockerApi from './dockerApi';
import type { Command } from '../utilsBundle';
import * as playwright from '../..';
const VRT_IMAGE_DISTRO = 'focal';
const VRT_IMAGE_NAME = `playwright:local-${getPlaywrightVersion()}-${VRT_IMAGE_DISTRO}`;
@ -28,24 +32,22 @@ const VRT_CONTAINER_NAME = `playwright-${getPlaywrightVersion()}-${VRT_IMAGE_DIS
const VRT_CONTAINER_LABEL_NAME = 'dev.playwright.vrt-service.version';
const VRT_CONTAINER_LABEL_VALUE = '1';
async function startPlaywrightContainer() {
async function startPlaywrightContainer(port: number) {
await checkDockerEngineIsRunningOrDie();
let info = await containerInfo();
if (!info) {
await stopAllPlaywrightContainers();
process.stdout.write(`Starting docker container... `);
const time = Date.now();
info = await ensurePlaywrightContainerOrDie();
const info = await ensurePlaywrightContainerOrDie(port);
const deltaMs = (Date.now() - time);
console.log('Done in ' + (deltaMs / 1000).toFixed(1) + 's');
}
await tetherHostNetwork(info.wsEndpoint);
console.log([
`- Endpoint: ${info.httpEndpoint}`,
`- View screen:`,
` ${info.vncSession}`,
`- Run tests with browsers inside container:`,
` npx playwright docker test`,
`- Stop background container *manually* when you are done working with tests:`,
` npx playwright docker stop`,
].join('\n'));
}
@ -130,6 +132,7 @@ async function buildPlaywrightImage() {
}
interface ContainerInfo {
httpEndpoint: string;
wsEndpoint: string;
vncSession: string;
}
@ -174,10 +177,14 @@ export async function containerInfo(): Promise<ContainerInfo|undefined> {
return undefined;
const wsEndpoint = containerUrlToHostUrl('ws://' + webSocketLine.substring(WS_LINE_PREFIX.length));
const vncSession = containerUrlToHostUrl(novncLine.substring(NOVNC_LINE_PREFIX.length));
return wsEndpoint && vncSession ? { wsEndpoint, vncSession } : undefined;
if (!wsEndpoint || !vncSession)
return undefined;
const wsUrl = new URL(wsEndpoint);
const httpEndpoint = 'http://' + wsUrl.host;
return { wsEndpoint, vncSession, httpEndpoint };
}
export async function ensurePlaywrightContainerOrDie(): Promise<ContainerInfo> {
export async function ensurePlaywrightContainerOrDie(port: number): Promise<ContainerInfo> {
const pwImage = await findDockerImage(VRT_IMAGE_NAME);
if (!pwImage) {
throw createStacklessError('\n' + utils.wrapInASCIIBox([
@ -228,14 +235,26 @@ export async function ensurePlaywrightContainerOrDie(): Promise<ContainerInfo> {
}
}
const env: Record<string, string | undefined> = {
PW_OWNED_BY_TETHER_CLIENT: '1',
DEBUG: process.env.DEBUG,
};
for (const [key, value] of Object.entries(process.env)) {
if (key.startsWith('PLAYWRIGHT_'))
env[key] = value;
}
await dockerApi.launchContainer({
imageId: pwImage.imageId,
name: VRT_CONTAINER_NAME,
autoRemove: true,
ports: [5400, 7900],
ports: [
{ container: 5400, host: port },
{ container: 7900, host: 0 },
],
labels: {
[VRT_CONTAINER_LABEL_NAME]: VRT_CONTAINER_LABEL_VALUE,
},
env,
});
// Wait for the service to become available.
@ -274,6 +293,34 @@ function createStacklessError(message: string) {
return error;
}
async function tetherHostNetwork(endpoint: string) {
const wsEndpoint = await urlToWSEndpoint(undefined /* progress */, endpoint);
const headers: any = {
'User-Agent': getUserAgent(),
'x-playwright-network-tethering': '1',
};
const transport = await WebSocketTransport.connect(undefined /* progress */, wsEndpoint, headers, true /* followRedirects */);
const socksInterceptor = new SocksInterceptor(transport, undefined);
transport.onmessage = json => socksInterceptor.interceptMessage(json);
transport.onclose = () => {
socksInterceptor.cleanup();
};
await transport.send({
id: 1,
guid: '',
method: 'initialize',
params: {
'sdkLanguage': 'javascript'
},
metadata: {
stack: [],
apiName: '',
internal: true
},
} as any);
}
export function addDockerCLI(program: Command) {
const dockerCommand = program.command('docker', { hidden: true })
.description(`Manage Docker integration (EXPERIMENTAL)`);
@ -291,9 +338,15 @@ export function addDockerCLI(program: Command) {
dockerCommand.command('start')
.description('start docker container')
.option('--port <port>', 'port to start container on. Auto-pick by default')
.action(async function(options) {
try {
await startPlaywrightContainer();
const port = options.port ? +options.port : 0;
if (isNaN(port)) {
console.error(`ERROR: bad port number "${options.port}"`);
process.exit(1);
}
await startPlaywrightContainer(port);
} catch (e) {
console.error(e.stack ? e : e.message);
process.exit(1);
@ -341,4 +394,50 @@ export function addDockerCLI(program: Command) {
.action(async function(options) {
await printDockerStatus();
});
dockerCommand.command('launch', { hidden: true })
.description('launch browser in container')
.option('--browser <name>', 'browser to launch')
.option('--endpoint <url>', 'server endpoint')
.action(async function(options: { browser: string, endpoint: string }) {
let browserType: any;
if (options.browser === 'chromium')
browserType = playwright.chromium;
else if (options.browser === 'firefox')
browserType = playwright.firefox;
else if (options.browser === 'webkit')
browserType = playwright.webkit;
if (!browserType) {
console.error('Unknown browser name: ', options.browser);
process.exit(1);
}
const browser = await browserType.connect(options.endpoint, {
headers: {
'x-playwright-launch-options': JSON.stringify({
headless: false,
viewport: null,
}),
'x-playwright-proxy': '*',
},
});
const context = await browser.newContext();
context.on('page', (page: playwright.Page) => {
page.on('dialog', () => {}); // Prevent dialogs from being automatically dismissed.
page.once('close', () => {
const hasPage = browser.contexts().some((context: playwright.BrowserContext) => context.pages().length > 0);
if (hasPage)
return;
// Avoid the error when the last page is closed because the browser has been closed.
browser.close().catch((e: Error) => null);
});
});
await context.newPage();
});
dockerCommand.command('tether', { hidden: true })
.description('tether local network to the playwright server')
.option('--endpoint <url>', 'server endpoint')
.action(async function(options: { endpoint: string }) {
await tetherHostNetwork(options.endpoint);
});
}

View file

@ -63,18 +63,19 @@ interface LaunchContainerOptions {
autoRemove: boolean;
command?: string[];
labels?: Record<string, string>;
ports?: Number[];
ports?: { container: number, host: number }[],
name?: string;
workingDir?: string;
waitUntil?: 'not-running' | 'next-exit' | 'removed';
env?: { [key: string]: string | number | boolean | undefined };
}
export async function launchContainer(options: LaunchContainerOptions): Promise<string> {
const ExposedPorts: any = {};
const PortBindings: any = {};
for (const port of (options.ports ?? [])) {
ExposedPorts[`${port}/tcp`] = {};
PortBindings[`${port}/tcp`] = [{ HostPort: '0', HostIp: '127.0.0.1' }];
ExposedPorts[`${port.container}/tcp`] = {};
PortBindings[`${port.container}/tcp`] = [{ HostPort: port.host + '', HostIp: '127.0.0.1' }];
}
const container = await postJSON(`/containers/create` + (options.name ? '?name=' + options.name : ''), {
Cmd: options.command,
@ -84,6 +85,7 @@ export async function launchContainer(options: LaunchContainerOptions): Promise<
AttachStderr: true,
Image: options.imageId,
ExposedPorts,
Env: dockerProtocolEnv(options.env),
HostConfig: {
Init: true,
AutoRemove: options.autoRemove,
@ -141,14 +143,18 @@ interface CommitContainerOptions {
env?: {[key: string]: string | number | boolean | undefined},
}
function dockerProtocolEnv(env?: {[key: string]: string | number | boolean | undefined}): string[] {
const result = [];
for (const [key, value] of Object.entries(env ?? {}))
result.push(`${key}=${value}`);
return result;
}
export async function commitContainer(options: CommitContainerOptions) {
const Env = [];
for (const [key, value] of Object.entries(options.env ?? {}))
Env.push(`${key}=${value}`);
await postJSON(`/commit?container=${options.containerId}&repo=${options.repo}&tag=${options.tag}`, {
Entrypoint: options.entrypoint,
WorkingDir: options.workingDir,
Env,
Env: dockerProtocolEnv(options.env),
});
}

View file

@ -19,14 +19,13 @@ import type { DispatcherScope, Playwright } from '../server';
import { createPlaywright, DispatcherConnection, RootDispatcher, PlaywrightDispatcher } from '../server';
import { Browser } from '../server/browser';
import { serverSideCallMetadata } from '../server/instrumentation';
import { gracefullyCloseAll } from '../utils/processLauncher';
import { SocksProxy } from '../common/socksProxy';
import { assert } from '../utils';
import type { LaunchOptions } from '../server/types';
import { AndroidDevice } from '../server/android/android';
import { DebugControllerDispatcher } from '../server/dispatchers/debugControllerDispatcher';
export type ClientType = 'controller' | 'playwright' | 'launch-browser' | 'reuse-browser' | 'pre-launched-browser';
export type ClientType = 'controller' | 'playwright' | 'launch-browser' | 'reuse-browser' | 'pre-launched-browser' | 'network-tethering';
type Options = {
enableSocksProxy: boolean,
@ -38,6 +37,7 @@ type PreLaunched = {
playwright?: Playwright | undefined;
browser?: Browser | undefined;
androidDevice?: AndroidDevice | undefined;
networkTetheringSocksProxy?: SocksProxy | undefined;
};
export class PlaywrightConnection {
@ -90,17 +90,27 @@ export class PlaywrightConnection {
return await this._initLaunchBrowserMode(scope);
if (clientType === 'playwright')
return await this._initPlaywrightConnectMode(scope);
if (clientType === 'network-tethering')
return await this._initPlaywrightTetheringMode(scope);
throw new Error('Unsupported client type: ' + clientType);
});
}
private async _initPlaywrightTetheringMode(scope: RootDispatcher) {
this._debugLog(`engaged playwright.tethering mode`);
const playwright = createPlaywright('javascript');
return new PlaywrightDispatcher(scope, playwright, this._preLaunched.networkTetheringSocksProxy);
}
private async _initPlaywrightConnectMode(scope: RootDispatcher) {
this._debugLog(`engaged playwright.connect mode`);
const playwright = createPlaywright('javascript');
// Close all launched browsers on disconnect.
this._cleanups.push(() => gracefullyCloseAll());
this._cleanups.push(async () => {
await Promise.all(playwright.allBrowsers().map(browser => browser.close()));
});
const socksProxy = this._options.enableSocksProxy ? await this._enableSocksProxy(playwright) : undefined;
const socksProxy = await this._configureSocksProxy(playwright);
return new PlaywrightDispatcher(scope, playwright, socksProxy);
}
@ -108,7 +118,7 @@ export class PlaywrightConnection {
this._debugLog(`engaged launch mode for "${this._options.browserName}"`);
const playwright = createPlaywright('javascript');
const socksProxy = this._options.enableSocksProxy ? await this._enableSocksProxy(playwright) : undefined;
const socksProxy = await this._configureSocksProxy(playwright);
const browser = await playwright[this._options.browserName as 'chromium'].launch(serverSideCallMetadata(), this._options.launchOptions);
this._cleanups.push(async () => {
@ -208,7 +218,14 @@ export class PlaywrightConnection {
return playwrightDispatcher;
}
private async _enableSocksProxy(playwright: Playwright) {
private async _configureSocksProxy(playwright: Playwright): Promise<undefined|SocksProxy> {
if (!this._options.enableSocksProxy)
return undefined;
if (this._preLaunched.networkTetheringSocksProxy) {
playwright.options.socksProxyPort = this._preLaunched.networkTetheringSocksProxy.port();
this._debugLog(`using network tether proxy on port ${playwright.options.socksProxyPort}`);
return undefined;
}
const socksProxy = new SocksProxy();
playwright.options.socksProxyPort = await socksProxy.listen(0);
this._debugLog(`started socks proxy on port ${playwright.options.socksProxyPort}`);

View file

@ -25,6 +25,7 @@ import type { ClientType } from './playwrightConnection';
import type { LaunchOptions } from '../server/types';
import { ManualPromise } from '../utils/manualPromise';
import type { AndroidDevice } from '../server/android/android';
import { SocksProxy } from '../common/socksProxy';
const debugLog = debug('pw:server');
@ -39,15 +40,18 @@ function newLogger() {
type ServerOptions = {
path: string;
maxConnections: number;
enableSocksProxy: boolean;
preLaunchedBrowser?: Browser
preLaunchedAndroidDevice?: AndroidDevice
browserProxyMode: 'client' | 'tether' | 'disabled',
ownedByTetherClient?: boolean;
};
export class PlaywrightServer {
private _preLaunchedPlaywright: Playwright | undefined;
private _wsServer: WebSocketServer | undefined;
private _networkTetheringSocksProxy: SocksProxy | undefined;
private _options: ServerOptions;
private _networkTetheringClientTimeout: NodeJS.Timeout | undefined;
constructor(options: ServerOptions) {
this._options = options;
@ -87,20 +91,31 @@ export class PlaywrightServer {
resolve(wsEndpoint);
}).on('error', reject);
});
if (this._options.browserProxyMode === 'tether') {
this._networkTetheringSocksProxy = new SocksProxy();
await this._networkTetheringSocksProxy.listen(0);
debugLog('Launched tethering proxy at ' + this._networkTetheringSocksProxy.port());
}
debugLog('Listening at ' + wsEndpoint);
if (this._options.ownedByTetherClient) {
this._networkTetheringClientTimeout = setTimeout(() => {
this.close();
}, 30_000);
}
this._wsServer = new wsServer({ server, path: this._options.path });
const browserSemaphore = new Semaphore(this._options.maxConnections);
const controllerSemaphore = new Semaphore(1);
const reuseBrowserSemaphore = new Semaphore(1);
const networkTetheringSemaphore = new Semaphore(1);
this._wsServer.on('connection', (ws, request) => {
const url = new URL('http://localhost' + (request.url || ''));
const browserHeader = request.headers['x-playwright-browser'];
const browserName = url.searchParams.get('browser') || (Array.isArray(browserHeader) ? browserHeader[0] : browserHeader) || null;
const proxyHeader = request.headers['x-playwright-proxy'];
const proxyValue = url.searchParams.get('proxy') || (Array.isArray(proxyHeader) ? proxyHeader[0] : proxyHeader);
const enableSocksProxy = this._options.enableSocksProxy && proxyValue === '*';
const enableSocksProxy = this._options.browserProxyMode !== 'disabled' && proxyValue === '*';
const launchOptionsHeader = request.headers['x-playwright-launch-options'] || '';
let launchOptions: LaunchOptions = {};
@ -112,8 +127,8 @@ export class PlaywrightServer {
const log = newLogger();
log(`serving connection: ${request.url}`);
const isDebugControllerClient = !!request.headers['x-playwright-debug-controller'];
const isNetworkTetheringClient = !!request.headers['x-playwright-network-tethering'];
const shouldReuseBrowser = !!request.headers['x-playwright-reuse-context'];
const semaphore = isDebugControllerClient ? controllerSemaphore : (shouldReuseBrowser ? reuseBrowserSemaphore : browserSemaphore);
// If we started in the legacy reuse-browser mode, create this._preLaunchedPlaywright.
// If we get a reuse-controller request, create this._preLaunchedPlaywright.
@ -121,21 +136,42 @@ export class PlaywrightServer {
this.preLaunchedPlaywright();
let clientType: ClientType = 'playwright';
if (isDebugControllerClient)
let semaphore: Semaphore = browserSemaphore;
if (isNetworkTetheringClient) {
clientType = 'network-tethering';
semaphore = networkTetheringSemaphore;
} else if (isDebugControllerClient) {
clientType = 'controller';
else if (shouldReuseBrowser)
semaphore = controllerSemaphore;
} else if (shouldReuseBrowser) {
clientType = 'reuse-browser';
else if (this._options.preLaunchedBrowser || this._options.preLaunchedAndroidDevice)
semaphore = reuseBrowserSemaphore;
} else if (this._options.preLaunchedBrowser || this._options.preLaunchedAndroidDevice) {
clientType = 'pre-launched-browser';
else if (browserName)
semaphore = browserSemaphore;
} else if (browserName) {
clientType = 'launch-browser';
semaphore = browserSemaphore;
}
if (clientType === 'network-tethering' && this._options.ownedByTetherClient)
clearTimeout(this._networkTetheringClientTimeout);
const connection = new PlaywrightConnection(
semaphore.aquire(),
clientType, ws,
{ enableSocksProxy, browserName, launchOptions },
{ playwright: this._preLaunchedPlaywright, browser: this._options.preLaunchedBrowser, androidDevice: this._options.preLaunchedAndroidDevice },
log, () => semaphore.release());
{
playwright: this._preLaunchedPlaywright,
browser: this._options.preLaunchedBrowser,
androidDevice: this._options.preLaunchedAndroidDevice,
networkTetheringSocksProxy: this._networkTetheringSocksProxy,
},
log, () => {
semaphore.release();
if (this._options.ownedByTetherClient && clientType === 'network-tethering')
this.close();
});
(ws as any)[kConnectionSymbol] = connection;
});
@ -146,6 +182,7 @@ export class PlaywrightServer {
const server = this._wsServer;
if (!server)
return;
await this._networkTetheringSocksProxy?.close();
debugLog('closing websocket server');
const waitForClose = new Promise(f => server.close(f));
// First disconnect all remaining clients.

View file

@ -14,7 +14,7 @@
* limitations under the License.
*/
import EventEmitter from 'events';
import type EventEmitter from 'events';
import fs from 'fs';
import path from 'path';
import type * as channels from '@protocol/channels';
@ -27,14 +27,12 @@ import { ZipFile } from '../../utils/zipFile';
import type * as har from '@trace/har';
import type { HeadersArray } from '../types';
import { JsonPipeDispatcher } from '../dispatchers/jsonPipeDispatcher';
import * as socks from '../../common/socksProxy';
import { WebSocketTransport } from '../transport';
import { SocksInterceptor } from '../socksInterceptor';
import type { CallMetadata } from '../instrumentation';
import { getUserAgent } from '../../common/userAgent';
import type { Progress } from '../progress';
import { ProgressController } from '../progress';
import { findValidator, ValidationError } from '../../protocol/validator';
import type { ValidatorContext } from '../../protocol/validator';
import { fetchData } from '../../common/netUtils';
import type { HTTPRequestParams } from '../../common/netUtils';
import type http from 'http';
@ -162,12 +160,10 @@ export class LocalUtilsDispatcher extends Dispatcher<{ guid: string }, channels.
const wsEndpoint = await urlToWSEndpoint(progress, params.wsEndpoint);
const transport = await WebSocketTransport.connect(progress, wsEndpoint, paramsHeaders, true);
let socksInterceptor: SocksInterceptor | undefined;
const socksInterceptor = new SocksInterceptor(transport, params.socksProxyRedirectPortForTest);
const pipe = new JsonPipeDispatcher(this);
transport.onmessage = json => {
if (json.method === '__create__' && json.params.type === 'SocksSupport')
socksInterceptor = new SocksInterceptor(transport, params.socksProxyRedirectPortForTest, json.params.guid);
if (socksInterceptor?.interceptMessage(json))
if (socksInterceptor.interceptMessage(json))
return;
const cb = () => {
try {
@ -317,62 +313,6 @@ class HarBackend {
}
}
class SocksInterceptor {
private _handler: socks.SocksProxyHandler;
private _channel: channels.SocksSupportChannel & EventEmitter;
private _socksSupportObjectGuid: string;
private _ids = new Set<number>();
constructor(transport: WebSocketTransport, redirectPortForTest: number | undefined, socksSupportObjectGuid: string) {
this._handler = new socks.SocksProxyHandler(redirectPortForTest);
this._socksSupportObjectGuid = socksSupportObjectGuid;
let lastId = -1;
this._channel = new Proxy(new EventEmitter(), {
get: (obj: any, prop) => {
if ((prop in obj) || obj[prop] !== undefined || typeof prop !== 'string')
return obj[prop];
return (params: any) => {
try {
const id = --lastId;
this._ids.add(id);
const validator = findValidator('SocksSupport', prop, 'Params');
params = validator(params, '', { tChannelImpl: tChannelForSocks, binary: 'toBase64' });
transport.send({ id, guid: socksSupportObjectGuid, method: prop, params, metadata: { stack: [], apiName: '', internal: true } } as any);
} catch (e) {
}
};
},
}) as channels.SocksSupportChannel & EventEmitter;
this._handler.on(socks.SocksProxyHandler.Events.SocksConnected, (payload: socks.SocksSocketConnectedPayload) => this._channel.socksConnected(payload));
this._handler.on(socks.SocksProxyHandler.Events.SocksData, (payload: socks.SocksSocketDataPayload) => this._channel.socksData(payload));
this._handler.on(socks.SocksProxyHandler.Events.SocksError, (payload: socks.SocksSocketErrorPayload) => this._channel.socksError(payload));
this._handler.on(socks.SocksProxyHandler.Events.SocksFailed, (payload: socks.SocksSocketFailedPayload) => this._channel.socksFailed(payload));
this._handler.on(socks.SocksProxyHandler.Events.SocksEnd, (payload: socks.SocksSocketEndPayload) => this._channel.socksEnd(payload));
this._channel.on('socksRequested', payload => this._handler.socketRequested(payload));
this._channel.on('socksClosed', payload => this._handler.socketClosed(payload));
this._channel.on('socksData', payload => this._handler.sendSocketData(payload));
}
cleanup() {
this._handler.cleanup();
}
interceptMessage(message: any): boolean {
if (this._ids.has(message.id)) {
this._ids.delete(message.id);
return true;
}
if (message.guid === this._socksSupportObjectGuid) {
const validator = findValidator('SocksSupport', message.method, 'Event');
const params = validator(message.params, '', { tChannelImpl: tChannelForSocks, binary: 'fromBase64' });
this._channel.emit(message.method, params);
return true;
}
return false;
}
}
function countMatchingHeaders(harHeaders: har.Header[], headers: HeadersArray): number {
const set = new Set(headers.map(h => h.name.toLowerCase() + ':' + h.value));
let matches = 0;
@ -383,15 +323,11 @@ function countMatchingHeaders(harHeaders: har.Header[], headers: HeadersArray):
return matches;
}
function tChannelForSocks(names: '*' | string[], arg: any, path: string, context: ValidatorContext) {
throw new ValidationError(`${path}: channels are not expected in SocksSupport`);
}
async function urlToWSEndpoint(progress: Progress, endpointURL: string): Promise<string> {
export async function urlToWSEndpoint(progress: Progress|undefined, endpointURL: string): Promise<string> {
if (endpointURL.startsWith('ws'))
return endpointURL;
progress.log(`<ws preparing> retrieving websocket url from ${endpointURL}`);
progress?.log(`<ws preparing> retrieving websocket url from ${endpointURL}`);
const fetchUrl = new URL(endpointURL);
if (!fetchUrl.pathname.endsWith('/'))
fetchUrl.pathname += '/';
@ -399,13 +335,13 @@ async function urlToWSEndpoint(progress: Progress, endpointURL: string): Promise
const json = await fetchData({
url: fetchUrl.toString(),
method: 'GET',
timeout: progress.timeUntilDeadline(),
timeout: progress?.timeUntilDeadline() ?? 30_000,
headers: { 'User-Agent': getUserAgent() },
}, async (params: HTTPRequestParams, response: http.IncomingMessage) => {
return new Error(`Unexpected status ${response.statusCode} when connecting to ${fetchUrl.toString()}.\n` +
`This does not look like a Playwright server, try connecting via ws://.`);
});
progress.throwIfAborted();
progress?.throwIfAborted();
const wsUrl = new URL(endpointURL);
let wsEndpointPath = JSON.parse(json).wsEndpointPath;

View file

@ -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.
*/
import * as socks from '../common/socksProxy';
import EventEmitter from 'events';
import type * as channels from '@protocol/channels';
import type { WebSocketTransport } from './transport';
import { findValidator, ValidationError } from '../protocol/validator';
import type { ValidatorContext } from '../protocol/validator';
export class SocksInterceptor {
private _handler: socks.SocksProxyHandler;
private _channel: channels.SocksSupportChannel & EventEmitter;
private _socksSupportObjectGuid?: string;
private _ids = new Set<number>();
constructor(transport: WebSocketTransport, redirectPortForTest: number | undefined) {
this._handler = new socks.SocksProxyHandler(redirectPortForTest);
let lastId = -1;
this._channel = new Proxy(new EventEmitter(), {
get: (obj: any, prop) => {
if ((prop in obj) || obj[prop] !== undefined || typeof prop !== 'string')
return obj[prop];
return (params: any) => {
try {
const id = --lastId;
this._ids.add(id);
const validator = findValidator('SocksSupport', prop, 'Params');
params = validator(params, '', { tChannelImpl: tChannelForSocks, binary: 'toBase64' });
transport.send({ id, guid: this._socksSupportObjectGuid, method: prop, params, metadata: { stack: [], apiName: '', internal: true } } as any);
} catch (e) {
}
};
},
}) as channels.SocksSupportChannel & EventEmitter;
this._handler.on(socks.SocksProxyHandler.Events.SocksConnected, (payload: socks.SocksSocketConnectedPayload) => this._channel.socksConnected(payload));
this._handler.on(socks.SocksProxyHandler.Events.SocksData, (payload: socks.SocksSocketDataPayload) => this._channel.socksData(payload));
this._handler.on(socks.SocksProxyHandler.Events.SocksError, (payload: socks.SocksSocketErrorPayload) => this._channel.socksError(payload));
this._handler.on(socks.SocksProxyHandler.Events.SocksFailed, (payload: socks.SocksSocketFailedPayload) => this._channel.socksFailed(payload));
this._handler.on(socks.SocksProxyHandler.Events.SocksEnd, (payload: socks.SocksSocketEndPayload) => this._channel.socksEnd(payload));
this._channel.on('socksRequested', payload => this._handler.socketRequested(payload));
this._channel.on('socksClosed', payload => this._handler.socketClosed(payload));
this._channel.on('socksData', payload => this._handler.sendSocketData(payload));
}
cleanup() {
this._handler.cleanup();
}
interceptMessage(message: any): boolean {
if (this._ids.has(message.id)) {
this._ids.delete(message.id);
return true;
}
if (message.method === '__create__' && message.params.type === 'SocksSupport') {
this._socksSupportObjectGuid = message.params.guid;
return false;
}
if (this._socksSupportObjectGuid && message.guid === this._socksSupportObjectGuid) {
const validator = findValidator('SocksSupport', message.method, 'Event');
const params = validator(message.params, '', { tChannelImpl: tChannelForSocks, binary: 'fromBase64' });
this._channel.emit(message.method, params);
return true;
}
return false;
}
}
function tChannelForSocks(names: '*' | string[], arg: any, path: string, context: ValidatorContext) {
throw new ValidationError(`${path}: channels are not expected in SocksSupport`);
}

View file

@ -48,27 +48,27 @@ export interface ConnectionTransport {
export class WebSocketTransport implements ConnectionTransport {
private _ws: WebSocket;
private _progress: Progress;
private _progress?: Progress;
onmessage?: (message: ProtocolResponse) => void;
onclose?: () => void;
readonly wsEndpoint: string;
static async connect(progress: Progress, url: string, headers?: { [key: string]: string; }, followRedirects?: boolean): Promise<WebSocketTransport> {
progress.log(`<ws connecting> ${url}`);
static async connect(progress: (Progress|undefined), url: string, headers?: { [key: string]: string; }, followRedirects?: boolean): Promise<WebSocketTransport> {
progress?.log(`<ws connecting> ${url}`);
const transport = new WebSocketTransport(progress, url, headers, followRedirects);
let success = false;
progress.cleanupWhenAborted(async () => {
progress?.cleanupWhenAborted(async () => {
if (!success)
await transport.closeAndWait().catch(e => null);
});
await new Promise<WebSocketTransport>((fulfill, reject) => {
transport._ws.on('open', async () => {
progress.log(`<ws connected> ${url}`);
progress?.log(`<ws connected> ${url}`);
fulfill(transport);
});
transport._ws.on('error', event => {
progress.log(`<ws connect error> ${url} ${event.message}`);
progress?.log(`<ws connect error> ${url} ${event.message}`);
reject(new Error('WebSocket error: ' + event.message));
transport._ws.close();
});
@ -78,7 +78,7 @@ export class WebSocketTransport implements ConnectionTransport {
response.on('data', chunk => chunks.push(chunk));
response.on('close', () => {
const error = chunks.length ? `${errorPrefix}\n${Buffer.concat(chunks)}` : errorPrefix;
progress.log(`<ws unexpected response> ${error}`);
progress?.log(`<ws unexpected response> ${error}`);
reject(new Error('WebSocket error: ' + error));
transport._ws.close();
});
@ -88,13 +88,13 @@ export class WebSocketTransport implements ConnectionTransport {
return transport;
}
constructor(progress: Progress, url: string, headers?: { [key: string]: string; }, followRedirects?: boolean) {
constructor(progress: Progress|undefined, url: string, headers?: { [key: string]: string; }, followRedirects?: boolean) {
this.wsEndpoint = url;
this._ws = new ws(url, [], {
perMessageDeflate: false,
maxPayload: 256 * 1024 * 1024, // 256Mb,
// Prevent internal http client error when passing negative timeout.
handshakeTimeout: Math.max(progress.timeUntilDeadline(), 1),
handshakeTimeout: Math.max(progress?.timeUntilDeadline() ?? 30_000, 1),
headers,
followRedirects,
});
@ -117,12 +117,12 @@ export class WebSocketTransport implements ConnectionTransport {
});
this._ws.addEventListener('close', event => {
this._progress && this._progress.log(`<ws disconnected> ${url} code=${event.code} reason=${event.reason}`);
this._progress?.log(`<ws disconnected> ${url} code=${event.code} reason=${event.reason}`);
if (this.onclose)
this.onclose.call(null);
});
// Prevent Error: read ECONNRESET.
this._ws.addEventListener('error', error => this._progress && this._progress.log(`<ws error> ${error.type} ${error.message}`));
this._ws.addEventListener('error', error => this._progress?.log(`<ws error> ${error.type} ${error.message}`));
}
send(message: ProtocolRequest) {
@ -130,7 +130,7 @@ export class WebSocketTransport implements ConnectionTransport {
}
close() {
this._progress && this._progress.log(`<ws disconnecting> ${this._ws.url}`);
this._progress?.log(`<ws disconnecting> ${this._ws.url}`);
this._ws.close();
}

View file

@ -17,7 +17,7 @@
import type { TestRunnerPlugin } from '.';
import type { FullConfig, Reporter, Suite } from '../../types/testReporter';
import { colors } from 'playwright-core/lib/utilsBundle';
import { checkDockerEngineIsRunningOrDie, containerInfo, ensurePlaywrightContainerOrDie } from 'playwright-core/lib/containers/docker';
import { checkDockerEngineIsRunningOrDie, containerInfo } from 'playwright-core/lib/containers/docker';
export const dockerPlugin: TestRunnerPlugin = {
name: 'playwright:docker',
@ -26,22 +26,13 @@ export const dockerPlugin: TestRunnerPlugin = {
if (!process.env.PLAYWRIGHT_DOCKER)
return;
const print = (text: string) => reporter.onStdOut?.(text);
const println = (text: string) => reporter.onStdOut?.(text + '\n');
println(colors.dim('Using docker container to run browsers.'));
await checkDockerEngineIsRunningOrDie();
let info = await containerInfo();
if (!info) {
print(colors.dim(`Starting docker container... `));
const time = Date.now();
info = await ensurePlaywrightContainerOrDie();
const deltaMs = (Date.now() - time);
println(colors.dim('Done in ' + (deltaMs / 1000).toFixed(1) + 's'));
println(colors.dim('The Docker container will keep running after tests finished.'));
println(colors.dim('Stop manually using:'));
println(colors.dim(' npx playwright docker stop'));
}
const info = await containerInfo();
if (!info)
throw new Error('ERROR: please launch docker container separately!');
println(colors.dim(`View screen: ${info.vncSession}`));
println('');
process.env.PW_TEST_CONNECT_WS_ENDPOINT = info.wsEndpoint;

View file

@ -16,7 +16,7 @@
import type { TestType, Fixtures } from '@playwright/test';
import { test } from '@playwright/test';
import type { CommonFixtures } from './commonFixtures';
import type { CommonFixtures, CommonWorkerFixtures } from './commonFixtures';
import { commonFixtures } from './commonFixtures';
import type { ServerFixtures, ServerWorkerOptions } from './serverFixtures';
import { serverFixtures } from './serverFixtures';
@ -36,7 +36,7 @@ export const baseTest = base
._extendTest(coverageTest)
._extendTest(platformTest)
._extendTest(testModeTest)
.extend<CommonFixtures>(commonFixtures)
.extend<CommonFixtures, CommonWorkerFixtures>(commonFixtures)
.extend<ServerFixtures, ServerWorkerOptions>(serverFixtures)
.extend<{}, { _snapshotSuffix: string }>({
_snapshotSuffix: ['', { scope: 'worker' }],

View file

@ -115,7 +115,11 @@ export type CommonFixtures = {
waitForPort: (port: number) => Promise<void>;
};
export const commonFixtures: Fixtures<CommonFixtures, {}> = {
export type CommonWorkerFixtures = {
daemonProcess: (params: TestChildParams) => TestChildProcess;
};
export const commonFixtures: Fixtures<CommonFixtures, CommonWorkerFixtures> = {
childProcess: async ({}, use, testInfo) => {
const processes: TestChildProcess[] = [];
await use(params => {
@ -133,6 +137,16 @@ export const commonFixtures: Fixtures<CommonFixtures, {}> = {
}
},
daemonProcess: [async ({}, use) => {
const processes: TestChildProcess[] = [];
await use(params => {
const process = new TestChildProcess(params);
processes.push(process);
return process;
});
await Promise.all(processes.map(child => child.close()));
}, { scope: 'worker' }],
waitForPort: async ({}, use) => {
const token = { canceled: false };
await use(async port => {

View file

@ -17,6 +17,7 @@ import { test, expect } from './npmTest';
import * as path from 'path';
import { TestServer } from '../../utils/testserver';
// Skipping docker tests on CI on non-linux since GHA does not have
// Docker engine installed on macOS and Windows.
test.skip(() => process.env.CI && process.platform !== 'linux');
@ -28,46 +29,30 @@ test.beforeAll(async ({ exec }) => {
});
});
test('make sure it tells to run `npx playwright docker build` when image is not instaleld', async ({ exec }) => {
test('make sure it tells to run `npx playwright docker build` when image is not installed', async ({ exec }) => {
await exec('npm i --foreground-scripts @playwright/test');
const result = await exec('npx playwright test docker.spec.js', {
const result = await exec('npx playwright docker start', {
expectToExitWithError: true,
env: { PLAYWRIGHT_DOCKER: '1' },
});
expect(result).toContain('npx playwright docker build');
});
test.describe('installed image', () => {
test.beforeAll(async ({ exec }) => {
test.beforeAll(async ({ exec, daemonProcess, waitForPort }) => {
await exec('npx playwright docker build', {
env: { PWTEST_DOCKER_BASE_IMAGE: 'playwright:installation-tests-focal' },
cwd: path.join(__dirname, '..', '..'),
});
});
test.afterAll(async ({ exec }) => {
await exec('npx playwright docker delete-image', {
cwd: path.join(__dirname, '..', '..'),
});
});
test('make sure it auto-starts container', async ({ exec }) => {
await exec('npm i --foreground-scripts @playwright/test');
await exec('npx playwright docker stop');
const result = await exec('npx playwright test docker.spec.js --grep platform', {
env: { PLAYWRIGHT_DOCKER: '1' },
});
expect(result).toContain('@chromium Linux');
});
test.describe('running container', () => {
test.beforeAll(async ({ exec }) => {
await exec('npx playwright docker start', {
const dockerProcess = await daemonProcess({
command: ['npx', 'playwright', 'docker', 'start', '--port=5667'],
shell: true,
cwd: path.join(__dirname, '..', '..'),
});
await dockerProcess.waitForOutput('- Endpoint:');
});
test.afterAll(async ({ exec }) => {
await exec('npx playwright docker stop', {
await exec('npx playwright docker delete-image', {
cwd: path.join(__dirname, '..', '..'),
});
});
@ -82,18 +67,6 @@ test.describe('installed image', () => {
expect(result).toContain('@firefox Linux');
});
test('supports PLAYWRIGHT_DOCKER env variable', async ({ exec }) => {
await exec('npm i --foreground-scripts @playwright/test');
const result = await exec('npx playwright test docker.spec.js --grep platform --browser all', {
env: {
PLAYWRIGHT_DOCKER: '1',
},
});
expect(result).toContain('@chromium Linux');
expect(result).toContain('@webkit Linux');
expect(result).toContain('@firefox Linux');
});
test('all browsers work headed', async ({ exec }) => {
await exec('npm i --foreground-scripts @playwright/test');
{
@ -140,14 +113,13 @@ test.describe('installed image', () => {
});
const result = await exec('npx playwright test docker.spec.js --grep localhost --browser all', {
env: {
PLAYWRIGHT_DOCKER: '1',
TEST_PORT: TEST_PORT + '',
PLAYWRIGHT_DOCKER: '1'
},
});
expect(result).toContain('@chromium Hello from host');
expect(result).toContain('@webkit Hello from host');
expect(result).toContain('@firefox Hello from host');
});
});
});

View file

@ -24,6 +24,8 @@ import path from 'path';
import debugLogger from 'debug';
import { Registry } from './registry';
import { spawnAsync } from './spawnAsync';
import type { CommonFixtures, CommonWorkerFixtures } from '../config/commonFixtures';
import { commonFixtures } from '../config/commonFixtures';
export const TMP_WORKSPACES = path.join(os.platform() === 'darwin' ? '/tmp' : os.tmpdir(), 'pwt', 'workspaces');
@ -76,7 +78,8 @@ const expect = _expect;
export type ExecOptions = { cwd?: string, env?: Record<string, string>, message?: string, expectToExitWithError?: boolean };
export type ArgsOrOptions = [] | [...string[]] | [...string[], ExecOptions] | [ExecOptions];
export const test = _test.extend<{
export type NPMTestFixtures = {
_auto: void,
tmpWorkspace: string,
nodeMajorVersion: number,
@ -85,7 +88,11 @@ export const test = _test.extend<{
exec: (cmd: string, ...argsAndOrOptions: ArgsOrOptions) => Promise<string>
tsc: (...argsAndOrOptions: ArgsOrOptions) => Promise<string>,
registry: Registry,
}>({
};
export const test = _test
.extend<CommonFixtures, CommonWorkerFixtures>(commonFixtures)
.extend<NPMTestFixtures>({
_auto: [async ({ tmpWorkspace, exec }, use) => {
await exec('npm init -y');
const sourceDir = path.join(__dirname, 'fixture-scripts');