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:
parent
91f7e3963d
commit
8538f61a72
1
.github/workflows/tests_primary.yml
vendored
1
.github/workflows/tests_primary.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,3 +2,6 @@
|
|||
../utils/
|
||||
../utilsBundle.ts
|
||||
../common/
|
||||
../server/
|
||||
../server/dispatchers/
|
||||
../..
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
86
packages/playwright-core/src/server/socksInterceptor.ts
Normal file
86
packages/playwright-core/src/server/socksInterceptor.ts
Normal 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`);
|
||||
}
|
||||
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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' }],
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Reference in a new issue