diff --git a/.github/workflows/tests_primary.yml b/.github/workflows/tests_primary.yml index f09faaeec2..d54b8a503d 100644 --- a/.github/workflows/tests_primary.yml +++ b/.github/workflows/tests_primary.yml @@ -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 diff --git a/packages/playwright-core/bin/container_install_deps.sh b/packages/playwright-core/bin/container_install_deps.sh index 35a37d0562..b0f10c5888 100755 --- a/packages/playwright-core/bin/container_install_deps.sh +++ b/packages/playwright-core/bin/container_install_deps.sh @@ -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(` - [begin] (fluxbox) - [submenu] (Browsers) {} - [exec] (Chromium) { ${chromium.executablePath()} --no-sandbox --test-type= } <> - [exec] (Firefox) { ${firefox.executablePath()} } <> - [exec] (WebKit) { ${webkit.executablePath()} } <> - [end] - [include] (/etc/X11/fluxbox/fluxbox-menu) +cat <<'EOF' > /root/.fluxbox/menu + [begin] (fluxbox) + [submenu] (Browsers) {} + [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 diff --git a/packages/playwright-core/bin/container_run_server.sh b/packages/playwright-core/bin/container_run_server.sh index 2bfef162f5..0813591741 100755 --- a/packages/playwright-core/bin/container_run_server.sh +++ b/packages/playwright-core/bin/container_run_server.sh @@ -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 + diff --git a/packages/playwright-core/src/androidServerImpl.ts b/packages/playwright-core/src/androidServerImpl.ts index 2ac01f25fa..dfbb1d43e5 100644 --- a/packages/playwright-core/src/androidServerImpl.ts +++ b/packages/playwright-core/src/androidServerImpl.ts @@ -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 diff --git a/packages/playwright-core/src/browserServerImpl.ts b/packages/playwright-core/src/browserServerImpl.ts index a602bda7a8..9e39a15385 100644 --- a/packages/playwright-core/src/browserServerImpl.ts +++ b/packages/playwright-core/src/browserServerImpl.ts @@ -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 diff --git a/packages/playwright-core/src/cli/cli.ts b/packages/playwright-core/src/cli/cli.ts index 4b751ec065..600eac9c37 100755 --- a/packages/playwright-core/src/cli/cli.ts +++ b/packages/playwright-core/src/cli/cli.ts @@ -270,9 +270,15 @@ program .option('--port ', 'Server port') .option('--path ', 'Endpoint Path', '/') .option('--max-clients ', 'Maximum clients') - .option('--no-socks-proxy', 'Disable Socks Proxy') + .option('--proxy-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 diff --git a/packages/playwright-core/src/cli/driver.ts b/packages/playwright-core/src/cli/driver.ts index 587fd6a36a..c44a57c537 100644 --- a/packages/playwright-core/src/cli/driver.ts +++ b/packages/playwright-core/src/cli/driver.ts @@ -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 diff --git a/packages/playwright-core/src/client/playwright.ts b/packages/playwright-core/src/client/playwright.ts index 6c8224693f..8f55c19e7b 100644 --- a/packages/playwright-core/src/client/playwright.ts +++ b/packages/playwright-core/src/client/playwright.ts @@ -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 { 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 { this.selectors._addChannel(selectorsOwner); this._connection.on('close', () => { this.selectors._removeChannel(selectorsOwner); - this._socksProxyHandler?.cleanup(); }); (global as any)._playwrightInstance = this; } diff --git a/packages/playwright-core/src/common/socksProxy.ts b/packages/playwright-core/src/common/socksProxy.ts index a46d20af55..166234468e 100644 --- a/packages/playwright-core/src/common/socksProxy.ts +++ b/packages/playwright-core/src/common/socksProxy.ts @@ -296,6 +296,7 @@ export class SocksProxy extends EventEmitter implements SocksConnectionClient { private _connections = new Map(); private _sockets = new Set(); 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 { 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); }); diff --git a/packages/playwright-core/src/containers/DEPS.list b/packages/playwright-core/src/containers/DEPS.list index c89b15d593..6d98651fb8 100644 --- a/packages/playwright-core/src/containers/DEPS.list +++ b/packages/playwright-core/src/containers/DEPS.list @@ -2,3 +2,6 @@ ../utils/ ../utilsBundle.ts ../common/ +../server/ +../server/dispatchers/ +../.. diff --git a/packages/playwright-core/src/containers/docker.ts b/packages/playwright-core/src/containers/docker.ts index 8461d6bf85..1d5b927d55 100644 --- a/packages/playwright-core/src/containers/docker.ts +++ b/packages/playwright-core/src/containers/docker.ts @@ -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) { - process.stdout.write(`Starting docker container... `); - const time = Date.now(); - info = await ensurePlaywrightContainerOrDie(); - const deltaMs = (Date.now() - time); - console.log('Done in ' + (deltaMs / 1000).toFixed(1) + 's'); - } + await stopAllPlaywrightContainers(); + + process.stdout.write(`Starting docker container... `); + const time = Date.now(); + 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`, + ` ${info.vncSession}`, ].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 { 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 { +export async function ensurePlaywrightContainerOrDie(port: number): Promise { const pwImage = await findDockerImage(VRT_IMAGE_NAME); if (!pwImage) { throw createStacklessError('\n' + utils.wrapInASCIIBox([ @@ -228,14 +235,26 @@ export async function ensurePlaywrightContainerOrDie(): Promise { } } + const env: Record = { + 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 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 ', 'browser to launch') + .option('--endpoint ', '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 ', 'server endpoint') + .action(async function(options: { endpoint: string }) { + await tetherHostNetwork(options.endpoint); + }); } diff --git a/packages/playwright-core/src/containers/dockerApi.ts b/packages/playwright-core/src/containers/dockerApi.ts index f084bb193b..95cb22f3d0 100644 --- a/packages/playwright-core/src/containers/dockerApi.ts +++ b/packages/playwright-core/src/containers/dockerApi.ts @@ -63,18 +63,19 @@ interface LaunchContainerOptions { autoRemove: boolean; command?: string[]; labels?: Record; - 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 { 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), }); } diff --git a/packages/playwright-core/src/remote/playwrightConnection.ts b/packages/playwright-core/src/remote/playwrightConnection.ts index 90ba632e9d..64300e91f9 100644 --- a/packages/playwright-core/src/remote/playwrightConnection.ts +++ b/packages/playwright-core/src/remote/playwrightConnection.ts @@ -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 { + 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}`); diff --git a/packages/playwright-core/src/remote/playwrightServer.ts b/packages/playwright-core/src/remote/playwrightServer.ts index 0354ea08c2..16cbd95796 100644 --- a/packages/playwright-core/src/remote/playwrightServer.ts +++ b/packages/playwright-core/src/remote/playwrightServer.ts @@ -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. diff --git a/packages/playwright-core/src/server/dispatchers/localUtilsDispatcher.ts b/packages/playwright-core/src/server/dispatchers/localUtilsDispatcher.ts index 26e99fc516..007d46bd3a 100644 --- a/packages/playwright-core/src/server/dispatchers/localUtilsDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/localUtilsDispatcher.ts @@ -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(); - - 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 { +export async function urlToWSEndpoint(progress: Progress|undefined, endpointURL: string): Promise { if (endpointURL.startsWith('ws')) return endpointURL; - progress.log(` retrieving websocket url from ${endpointURL}`); + progress?.log(` 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; diff --git a/packages/playwright-core/src/server/socksInterceptor.ts b/packages/playwright-core/src/server/socksInterceptor.ts new file mode 100644 index 0000000000..2df184cfeb --- /dev/null +++ b/packages/playwright-core/src/server/socksInterceptor.ts @@ -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(); + + 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`); +} + diff --git a/packages/playwright-core/src/server/transport.ts b/packages/playwright-core/src/server/transport.ts index b3766acba7..84feed7ca6 100644 --- a/packages/playwright-core/src/server/transport.ts +++ b/packages/playwright-core/src/server/transport.ts @@ -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 { - progress.log(` ${url}`); + static async connect(progress: (Progress|undefined), url: string, headers?: { [key: string]: string; }, followRedirects?: boolean): Promise { + progress?.log(` ${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((fulfill, reject) => { transport._ws.on('open', async () => { - progress.log(` ${url}`); + progress?.log(` ${url}`); fulfill(transport); }); transport._ws.on('error', event => { - progress.log(` ${url} ${event.message}`); + progress?.log(` ${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(` ${error}`); + progress?.log(` ${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(` ${url} code=${event.code} reason=${event.reason}`); + this._progress?.log(` ${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(` ${error.type} ${error.message}`)); + this._ws.addEventListener('error', error => this._progress?.log(` ${error.type} ${error.message}`)); } send(message: ProtocolRequest) { @@ -130,7 +130,7 @@ export class WebSocketTransport implements ConnectionTransport { } close() { - this._progress && this._progress.log(` ${this._ws.url}`); + this._progress?.log(` ${this._ws.url}`); this._ws.close(); } diff --git a/packages/playwright-test/src/plugins/dockerPlugin.ts b/packages/playwright-test/src/plugins/dockerPlugin.ts index c9e6a463e5..297dbda76d 100644 --- a/packages/playwright-test/src/plugins/dockerPlugin.ts +++ b/packages/playwright-test/src/plugins/dockerPlugin.ts @@ -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; diff --git a/tests/config/baseTest.ts b/tests/config/baseTest.ts index 96995f62ba..f5920df926 100644 --- a/tests/config/baseTest.ts +++ b/tests/config/baseTest.ts @@ -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) + .extend(commonFixtures) .extend(serverFixtures) .extend<{}, { _snapshotSuffix: string }>({ _snapshotSuffix: ['', { scope: 'worker' }], diff --git a/tests/config/commonFixtures.ts b/tests/config/commonFixtures.ts index b629a762b8..344401668c 100644 --- a/tests/config/commonFixtures.ts +++ b/tests/config/commonFixtures.ts @@ -115,7 +115,11 @@ export type CommonFixtures = { waitForPort: (port: number) => Promise; }; -export const commonFixtures: Fixtures = { +export type CommonWorkerFixtures = { + daemonProcess: (params: TestChildParams) => TestChildProcess; +}; + +export const commonFixtures: Fixtures = { childProcess: async ({}, use, testInfo) => { const processes: TestChildProcess[] = []; await use(params => { @@ -133,6 +137,16 @@ export const commonFixtures: Fixtures = { } }, + 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 => { diff --git a/tests/installation/docker-integration.spec.ts b/tests/installation/docker-integration.spec.ts index 8989e06295..7cb64cf40e 100755 --- a/tests/installation/docker-integration.spec.ts +++ b/tests/installation/docker-integration.spec.ts @@ -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,126 +29,97 @@ 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, '..', '..'), }); + 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 delete-image', { cwd: path.join(__dirname, '..', '..'), }); }); - test('make sure it auto-starts container', async ({ exec }) => { + test('all browsers work headless', 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', { + 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.describe('running container', () => { - test.beforeAll(async ({ exec }) => { - await exec('npx playwright docker start', { - cwd: path.join(__dirname, '..', '..'), - }); - }); - - test.afterAll(async ({ exec }) => { - await exec('npx playwright docker stop', { - cwd: path.join(__dirname, '..', '..'), - }); - }); - - test('all browsers work headless', async ({ exec }) => { - await exec('npm i --foreground-scripts @playwright/test'); - const result = await exec('npx playwright test docker.spec.js --grep platform --browser all', { + test('all browsers work headed', async ({ exec }) => { + await exec('npm i --foreground-scripts @playwright/test'); + { + const result = await exec(`npx playwright test docker.spec.js --headed --grep userAgent --browser chromium`, { env: { PLAYWRIGHT_DOCKER: '1' }, }); - expect(result).toContain('@chromium Linux'); - expect(result).toContain('@webkit Linux'); - 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'); - { - const result = await exec(`npx playwright test docker.spec.js --headed --grep userAgent --browser chromium`, { - env: { PLAYWRIGHT_DOCKER: '1' }, - }); - expect(result).toContain('@chromium'); - expect(result).not.toContain('Headless'); - expect(result).toContain(' Chrome/'); - } - { - const result = await exec(`npx playwright test docker.spec.js --headed --grep userAgent --browser webkit`, { - env: { PLAYWRIGHT_DOCKER: '1' }, - }); - expect(result).toContain('@webkit'); - expect(result).toContain(' Version/'); - } - { - const result = await exec(`npx playwright test docker.spec.js --headed --grep userAgent --browser firefox`, { - env: { PLAYWRIGHT_DOCKER: '1' }, - }); - expect(result).toContain('@firefox'); - expect(result).toContain(' Firefox/'); - } - }); - - test('screenshots should use __screenshots__ folder', async ({ exec, tmpWorkspace }) => { - await exec('npm i --foreground-scripts @playwright/test'); - await exec('npx playwright test docker.spec.js --grep screenshot --browser all', { - expectToExitWithError: true, + expect(result).toContain('@chromium'); + expect(result).not.toContain('Headless'); + expect(result).toContain(' Chrome/'); + } + { + const result = await exec(`npx playwright test docker.spec.js --headed --grep userAgent --browser webkit`, { env: { PLAYWRIGHT_DOCKER: '1' }, }); - await expect(path.join(tmpWorkspace, '__screenshots__', 'firefox', 'docker.spec.js', 'img.png')).toExistOnFS(); - await expect(path.join(tmpWorkspace, '__screenshots__', 'chromium', 'docker.spec.js', 'img.png')).toExistOnFS(); - await expect(path.join(tmpWorkspace, '__screenshots__', 'webkit', 'docker.spec.js', 'img.png')).toExistOnFS(); - }); + expect(result).toContain('@webkit'); + expect(result).toContain(' Version/'); + } + { + const result = await exec(`npx playwright test docker.spec.js --headed --grep userAgent --browser firefox`, { + env: { PLAYWRIGHT_DOCKER: '1' }, + }); + expect(result).toContain('@firefox'); + expect(result).toContain(' Firefox/'); + } + }); - test('port forwarding works', async ({ exec, tmpWorkspace }) => { - await exec('npm i --foreground-scripts @playwright/test'); - const TEST_PORT = 8425; - const server = await TestServer.create(tmpWorkspace, TEST_PORT); - server.setRoute('/', (request, response) => { - response.end('Hello from host'); - }); - const result = await exec('npx playwright test docker.spec.js --grep localhost --browser all', { - env: { - PLAYWRIGHT_DOCKER: '1', - TEST_PORT: TEST_PORT + '', - }, - }); - expect(result).toContain('@chromium Hello from host'); - expect(result).toContain('@webkit Hello from host'); - expect(result).toContain('@firefox Hello from host'); + test('screenshots should use __screenshots__ folder', async ({ exec, tmpWorkspace }) => { + await exec('npm i --foreground-scripts @playwright/test'); + await exec('npx playwright test docker.spec.js --grep screenshot --browser all', { + expectToExitWithError: true, + env: { PLAYWRIGHT_DOCKER: '1' }, }); + await expect(path.join(tmpWorkspace, '__screenshots__', 'firefox', 'docker.spec.js', 'img.png')).toExistOnFS(); + await expect(path.join(tmpWorkspace, '__screenshots__', 'chromium', 'docker.spec.js', 'img.png')).toExistOnFS(); + await expect(path.join(tmpWorkspace, '__screenshots__', 'webkit', 'docker.spec.js', 'img.png')).toExistOnFS(); + }); + + test('port forwarding works', async ({ exec, tmpWorkspace }) => { + await exec('npm i --foreground-scripts @playwright/test'); + const TEST_PORT = 8425; + const server = await TestServer.create(tmpWorkspace, TEST_PORT); + server.setRoute('/', (request, response) => { + response.end('Hello from host'); + }); + const result = await exec('npx playwright test docker.spec.js --grep localhost --browser all', { + env: { + 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'); }); }); diff --git a/tests/installation/npmTest.ts b/tests/installation/npmTest.ts index 3d52228496..ac4f0b308d 100644 --- a/tests/installation/npmTest.ts +++ b/tests/installation/npmTest.ts @@ -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,111 +78,116 @@ const expect = _expect; export type ExecOptions = { cwd?: string, env?: Record, message?: string, expectToExitWithError?: boolean }; export type ArgsOrOptions = [] | [...string[]] | [...string[], ExecOptions] | [ExecOptions]; -export const test = _test.extend<{ - _auto: void, - tmpWorkspace: string, - nodeMajorVersion: number, - installedSoftwareOnDisk: (registryPath?: string) => Promise; - writeFiles: (nameToContents: Record) => Promise, - exec: (cmd: string, ...argsAndOrOptions: ArgsOrOptions) => Promise - tsc: (...argsAndOrOptions: ArgsOrOptions) => Promise, - registry: Registry, - }>({ - _auto: [async ({ tmpWorkspace, exec }, use) => { - await exec('npm init -y'); - const sourceDir = path.join(__dirname, 'fixture-scripts'); - const contents = await fs.promises.readdir(sourceDir); - await Promise.all(contents.map(f => fs.promises.copyFile(path.join(sourceDir, f), path.join(tmpWorkspace, f)))); - await use(); - }, { - auto: true, - }], - nodeMajorVersion: async ({}, use) => { - await use(+process.versions.node.split('.')[0]); - }, - writeFiles: async ({ tmpWorkspace }, use) => { - await use(async (nameToContents: Record) => { - for (const [name, contents] of Object.entries(nameToContents)) - await fs.promises.writeFile(path.join(tmpWorkspace, name), contents); - }); - }, - tmpWorkspace: async ({}, use) => { - // We want a location that won't have a node_modules dir anywhere along its path - const tmpWorkspace = path.join(TMP_WORKSPACES, path.basename(test.info().outputDir)); - await fs.promises.mkdir(tmpWorkspace); - debug(`Workspace Folder: ${tmpWorkspace}`); - await use(tmpWorkspace); - }, - registry: async ({}, use, testInfo) => { - const port = testInfo.workerIndex + 16123; - const url = `http://127.0.0.1:${port}`; - const registry = new Registry(testInfo.outputPath('registry'), url); - await registry.start(JSON.parse((await fs.promises.readFile(path.join(__dirname, '.registry.json'), 'utf8')))); - await use(registry); - await registry.shutdown(); - }, - installedSoftwareOnDisk: async ({ tmpWorkspace }, use) => { - await use(async (registryPath?: string) => fs.promises.readdir(registryPath || path.join(tmpWorkspace, 'browsers')).catch(() => []).then(files => files.map(f => f.split('-')[0]).filter(f => !f.startsWith('.')))); - }, - exec: async ({ registry, tmpWorkspace }, use, testInfo) => { - await use(async (cmd: string, ...argsAndOrOptions: [] | [...string[]] | [...string[], ExecOptions] | [ExecOptions]) => { - let args: string[] = []; - let options: ExecOptions = {}; - if (typeof argsAndOrOptions[argsAndOrOptions.length - 1] === 'object') - options = argsAndOrOptions.pop() as ExecOptions; - args = argsAndOrOptions as string[]; +export type NPMTestFixtures = { + _auto: void, + tmpWorkspace: string, + nodeMajorVersion: number, + installedSoftwareOnDisk: (registryPath?: string) => Promise; + writeFiles: (nameToContents: Record) => Promise, + exec: (cmd: string, ...argsAndOrOptions: ArgsOrOptions) => Promise + tsc: (...argsAndOrOptions: ArgsOrOptions) => Promise, + registry: Registry, +}; - let result!: Awaited>; - await test.step(`exec: ${[cmd, ...args].join(' ')}`, async () => { - result = await spawnAsync(cmd, args, { - shell: true, - cwd: options.cwd ?? tmpWorkspace, - // NB: We end up running npm-in-npm, so it's important that we do NOT forward process.env and instead cherry-pick environment variables. - env: { - 'PATH': process.env.PATH, - 'DISPLAY': process.env.DISPLAY, - 'XAUTHORITY': process.env.XAUTHORITY, - 'PLAYWRIGHT_BROWSERS_PATH': path.join(tmpWorkspace, 'browsers'), - 'npm_config_cache': testInfo.outputPath('npm_cache'), - 'npm_config_registry': registry.url(), - 'npm_config_prefix': testInfo.outputPath('npm_global'), - ...options.env, - } - }); - }); - - const command = [cmd, ...args].join(' '); - const stdio = result.stdout + result.stderr; - await testInfo.attach(command, { body: `COMMAND: ${command}\n\nEXIT CODE: ${result.code}\n\n====== STDOUT + STDERR ======\n\n${stdio}` }); - - // This means something is really off with spawn - if (result.error) - throw result.error; - - const error: string[] = []; - if (options.expectToExitWithError && result.code === 0) - error.push(`Expected the command to exit with an error, but exited cleanly.`); - else if (!options.expectToExitWithError && result.code !== 0) - error.push(`Expected the command to exit cleanly (0 status code), but exited with ${result.code}.`); - - if (!error.length) - return stdio; - - if (options.message) - error.push(`Message: ${options.message}`); - error.push(`Command: ${command}`); - error.push(`EXIT CODE: ${result.code}`); - error.push(`====== STDIO ======\n${stdio}`); - - throw new Error(error.join('\n')); - }); - }, - tsc: async ({ exec }, use) => { - await exec('npm i --foreground-scripts typescript@3.8 @types/node@14'); - await use((...args: ArgsOrOptions) => exec('npx', '-p', 'typescript@3.8', 'tsc', ...args)); - }, +export const test = _test + .extend(commonFixtures) + .extend({ + _auto: [async ({ tmpWorkspace, exec }, use) => { + await exec('npm init -y'); + const sourceDir = path.join(__dirname, 'fixture-scripts'); + const contents = await fs.promises.readdir(sourceDir); + await Promise.all(contents.map(f => fs.promises.copyFile(path.join(sourceDir, f), path.join(tmpWorkspace, f)))); + await use(); + }, { + auto: true, + }], + nodeMajorVersion: async ({}, use) => { + await use(+process.versions.node.split('.')[0]); + }, + writeFiles: async ({ tmpWorkspace }, use) => { + await use(async (nameToContents: Record) => { + for (const [name, contents] of Object.entries(nameToContents)) + await fs.promises.writeFile(path.join(tmpWorkspace, name), contents); }); + }, + tmpWorkspace: async ({}, use) => { + // We want a location that won't have a node_modules dir anywhere along its path + const tmpWorkspace = path.join(TMP_WORKSPACES, path.basename(test.info().outputDir)); + await fs.promises.mkdir(tmpWorkspace); + debug(`Workspace Folder: ${tmpWorkspace}`); + await use(tmpWorkspace); + }, + registry: async ({}, use, testInfo) => { + const port = testInfo.workerIndex + 16123; + const url = `http://127.0.0.1:${port}`; + const registry = new Registry(testInfo.outputPath('registry'), url); + await registry.start(JSON.parse((await fs.promises.readFile(path.join(__dirname, '.registry.json'), 'utf8')))); + await use(registry); + await registry.shutdown(); + }, + installedSoftwareOnDisk: async ({ tmpWorkspace }, use) => { + await use(async (registryPath?: string) => fs.promises.readdir(registryPath || path.join(tmpWorkspace, 'browsers')).catch(() => []).then(files => files.map(f => f.split('-')[0]).filter(f => !f.startsWith('.')))); + }, + exec: async ({ registry, tmpWorkspace }, use, testInfo) => { + await use(async (cmd: string, ...argsAndOrOptions: [] | [...string[]] | [...string[], ExecOptions] | [ExecOptions]) => { + let args: string[] = []; + let options: ExecOptions = {}; + if (typeof argsAndOrOptions[argsAndOrOptions.length - 1] === 'object') + options = argsAndOrOptions.pop() as ExecOptions; + + args = argsAndOrOptions as string[]; + + let result!: Awaited>; + await test.step(`exec: ${[cmd, ...args].join(' ')}`, async () => { + result = await spawnAsync(cmd, args, { + shell: true, + cwd: options.cwd ?? tmpWorkspace, + // NB: We end up running npm-in-npm, so it's important that we do NOT forward process.env and instead cherry-pick environment variables. + env: { + 'PATH': process.env.PATH, + 'DISPLAY': process.env.DISPLAY, + 'XAUTHORITY': process.env.XAUTHORITY, + 'PLAYWRIGHT_BROWSERS_PATH': path.join(tmpWorkspace, 'browsers'), + 'npm_config_cache': testInfo.outputPath('npm_cache'), + 'npm_config_registry': registry.url(), + 'npm_config_prefix': testInfo.outputPath('npm_global'), + ...options.env, + } + }); + }); + + const command = [cmd, ...args].join(' '); + const stdio = result.stdout + result.stderr; + await testInfo.attach(command, { body: `COMMAND: ${command}\n\nEXIT CODE: ${result.code}\n\n====== STDOUT + STDERR ======\n\n${stdio}` }); + + // This means something is really off with spawn + if (result.error) + throw result.error; + + const error: string[] = []; + if (options.expectToExitWithError && result.code === 0) + error.push(`Expected the command to exit with an error, but exited cleanly.`); + else if (!options.expectToExitWithError && result.code !== 0) + error.push(`Expected the command to exit cleanly (0 status code), but exited with ${result.code}.`); + + if (!error.length) + return stdio; + + if (options.message) + error.push(`Message: ${options.message}`); + error.push(`Command: ${command}`); + error.push(`EXIT CODE: ${result.code}`); + error.push(`====== STDIO ======\n${stdio}`); + + throw new Error(error.join('\n')); + }); + }, + tsc: async ({ exec }, use) => { + await exec('npm i --foreground-scripts typescript@3.8 @types/node@14'); + await use((...args: ArgsOrOptions) => exec('npx', '-p', 'typescript@3.8', 'tsc', ...args)); + }, + }); export { expect };