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: |
|
- run: |
|
||||||
./utils/docker/build.sh --amd64 focal $PWTEST_DOCKER_BASE_IMAGE
|
./utils/docker/build.sh --amd64 focal $PWTEST_DOCKER_BASE_IMAGE
|
||||||
npx playwright docker build
|
npx playwright docker build
|
||||||
|
nohup npx playwright docker start &
|
||||||
xvfb-run npm run test-html-reporter
|
xvfb-run npm run test-html-reporter
|
||||||
env:
|
env:
|
||||||
PLAYWRIGHT_DOCKER: 1
|
PLAYWRIGHT_DOCKER: 1
|
||||||
|
|
|
||||||
|
|
@ -67,20 +67,15 @@ git apply clip.patch
|
||||||
|
|
||||||
# Configure FluxBox menus
|
# Configure FluxBox menus
|
||||||
mkdir /root/.fluxbox
|
mkdir /root/.fluxbox
|
||||||
cd /ms-playwright-agent
|
cat <<'EOF' > /root/.fluxbox/menu
|
||||||
cat <<'EOF' | node > /root/.fluxbox/menu
|
[begin] (fluxbox)
|
||||||
const { chromium, firefox, webkit } = require('playwright-core');
|
[submenu] (Browsers) {}
|
||||||
|
[exec] (Chromium) { /ms-playwright-agent/node_modules/.bin/playwright docker launch --endpoint http://127.0.0.1:5400 --browser chromium } <>
|
||||||
console.log(`
|
[exec] (Firefox) { /ms-playwright-agent/node_modules/.bin/playwright docker launch --endpoint http://127.0.0.1:5400 --browser firefox } <>
|
||||||
[begin] (fluxbox)
|
[exec] (WebKit) { /ms-playwright-agent/node_modules/.bin/playwright docker launch --endpoint http://127.0.0.1:5400 --browser webkit } <>
|
||||||
[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)
|
|
||||||
[end]
|
[end]
|
||||||
`);
|
[include] (/etc/X11/fluxbox/fluxbox-menu)
|
||||||
|
[end]
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
cat <<'EOF' > /root/.fluxbox/lastwallpaper
|
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"
|
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)
|
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()}`;
|
const path = options.wsPath ? (options.wsPath.startsWith('/') ? options.wsPath : `/${options.wsPath}`) : `/${createGuid()}`;
|
||||||
|
|
||||||
// 2. Start the server
|
// 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);
|
const wsEndpoint = await server.listen(options.port);
|
||||||
|
|
||||||
// 3. Return the BrowserServer interface
|
// 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()}`;
|
const path = options.wsPath ? (options.wsPath.startsWith('/') ? options.wsPath : `/${options.wsPath}`) : `/${createGuid()}`;
|
||||||
|
|
||||||
// 2. Start the server
|
// 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);
|
const wsEndpoint = await server.listen(options.port);
|
||||||
|
|
||||||
// 3. Return the BrowserServer interface
|
// 3. Return the BrowserServer interface
|
||||||
|
|
|
||||||
|
|
@ -270,9 +270,15 @@ program
|
||||||
.option('--port <port>', 'Server port')
|
.option('--port <port>', 'Server port')
|
||||||
.option('--path <path>', 'Endpoint Path', '/')
|
.option('--path <path>', 'Endpoint Path', '/')
|
||||||
.option('--max-clients <maxClients>', 'Maximum clients')
|
.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) {
|
.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
|
program
|
||||||
|
|
|
||||||
|
|
@ -46,8 +46,23 @@ export function runDriver() {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runServer(port: number | undefined, path = '/', maxConnections = Infinity, enableSocksProxy = true) {
|
export type RunServerOptions = {
|
||||||
const server = new PlaywrightServer({ path, maxConnections, enableSocksProxy });
|
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);
|
const wsEndpoint = await server.listen(port);
|
||||||
process.on('exit', () => server.close().catch(console.error));
|
process.on('exit', () => server.close().catch(console.error));
|
||||||
console.log('Listening on ' + wsEndpoint); // eslint-disable-line no-console
|
console.log('Listening on ' + wsEndpoint); // eslint-disable-line no-console
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,6 @@
|
||||||
|
|
||||||
import type * as channels from '@protocol/channels';
|
import type * as channels from '@protocol/channels';
|
||||||
import { TimeoutError } from '../common/errors';
|
import { TimeoutError } from '../common/errors';
|
||||||
import type * as socks from '../common/socksProxy';
|
|
||||||
import { Android } from './android';
|
import { Android } from './android';
|
||||||
import { BrowserType } from './browserType';
|
import { BrowserType } from './browserType';
|
||||||
import { ChannelOwner } from './channelOwner';
|
import { ChannelOwner } from './channelOwner';
|
||||||
|
|
@ -45,7 +44,6 @@ export class Playwright extends ChannelOwner<channels.PlaywrightChannel> {
|
||||||
selectors: Selectors;
|
selectors: Selectors;
|
||||||
readonly request: APIRequest;
|
readonly request: APIRequest;
|
||||||
readonly errors: { TimeoutError: typeof TimeoutError };
|
readonly errors: { TimeoutError: typeof TimeoutError };
|
||||||
private _socksProxyHandler: socks.SocksProxyHandler | undefined;
|
|
||||||
|
|
||||||
constructor(parent: ChannelOwner, type: string, guid: string, initializer: channels.PlaywrightInitializer) {
|
constructor(parent: ChannelOwner, type: string, guid: string, initializer: channels.PlaywrightInitializer) {
|
||||||
super(parent, type, guid, initializer);
|
super(parent, type, guid, initializer);
|
||||||
|
|
@ -68,7 +66,6 @@ export class Playwright extends ChannelOwner<channels.PlaywrightChannel> {
|
||||||
this.selectors._addChannel(selectorsOwner);
|
this.selectors._addChannel(selectorsOwner);
|
||||||
this._connection.on('close', () => {
|
this._connection.on('close', () => {
|
||||||
this.selectors._removeChannel(selectorsOwner);
|
this.selectors._removeChannel(selectorsOwner);
|
||||||
this._socksProxyHandler?.cleanup();
|
|
||||||
});
|
});
|
||||||
(global as any)._playwrightInstance = this;
|
(global as any)._playwrightInstance = this;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -296,6 +296,7 @@ export class SocksProxy extends EventEmitter implements SocksConnectionClient {
|
||||||
private _connections = new Map<string, SocksConnection>();
|
private _connections = new Map<string, SocksConnection>();
|
||||||
private _sockets = new Set<net.Socket>();
|
private _sockets = new Set<net.Socket>();
|
||||||
private _closed = false;
|
private _closed = false;
|
||||||
|
private _port: number | undefined;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
|
|
@ -314,10 +315,15 @@ export class SocksProxy extends EventEmitter implements SocksConnectionClient {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
port() {
|
||||||
|
return this._port;
|
||||||
|
}
|
||||||
|
|
||||||
async listen(port: number): Promise<number> {
|
async listen(port: number): Promise<number> {
|
||||||
return new Promise(f => {
|
return new Promise(f => {
|
||||||
this._server.listen(port, () => {
|
this._server.listen(port, () => {
|
||||||
const port = (this._server.address() as AddressInfo).port;
|
const port = (this._server.address() as AddressInfo).port;
|
||||||
|
this._port = port;
|
||||||
debugLogger.log('proxy', `Starting socks proxy server on port ${port}`);
|
debugLogger.log('proxy', `Starting socks proxy server on port ${port}`);
|
||||||
f(port);
|
f(port);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -2,3 +2,6 @@
|
||||||
../utils/
|
../utils/
|
||||||
../utilsBundle.ts
|
../utilsBundle.ts
|
||||||
../common/
|
../common/
|
||||||
|
../server/
|
||||||
|
../server/dispatchers/
|
||||||
|
../..
|
||||||
|
|
|
||||||
|
|
@ -18,9 +18,13 @@
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { spawnAsync } from '../utils/spawnAsync';
|
import { spawnAsync } from '../utils/spawnAsync';
|
||||||
import * as utils from '../utils';
|
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 * as dockerApi from './dockerApi';
|
||||||
import type { Command } from '../utilsBundle';
|
import type { Command } from '../utilsBundle';
|
||||||
|
import * as playwright from '../..';
|
||||||
|
|
||||||
const VRT_IMAGE_DISTRO = 'focal';
|
const VRT_IMAGE_DISTRO = 'focal';
|
||||||
const VRT_IMAGE_NAME = `playwright:local-${getPlaywrightVersion()}-${VRT_IMAGE_DISTRO}`;
|
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_NAME = 'dev.playwright.vrt-service.version';
|
||||||
const VRT_CONTAINER_LABEL_VALUE = '1';
|
const VRT_CONTAINER_LABEL_VALUE = '1';
|
||||||
|
|
||||||
async function startPlaywrightContainer() {
|
async function startPlaywrightContainer(port: number) {
|
||||||
await checkDockerEngineIsRunningOrDie();
|
await checkDockerEngineIsRunningOrDie();
|
||||||
|
|
||||||
let info = await containerInfo();
|
await stopAllPlaywrightContainers();
|
||||||
if (!info) {
|
|
||||||
process.stdout.write(`Starting docker container... `);
|
process.stdout.write(`Starting docker container... `);
|
||||||
const time = Date.now();
|
const time = Date.now();
|
||||||
info = await ensurePlaywrightContainerOrDie();
|
const info = await ensurePlaywrightContainerOrDie(port);
|
||||||
const deltaMs = (Date.now() - time);
|
const deltaMs = (Date.now() - time);
|
||||||
console.log('Done in ' + (deltaMs / 1000).toFixed(1) + 's');
|
console.log('Done in ' + (deltaMs / 1000).toFixed(1) + 's');
|
||||||
}
|
await tetherHostNetwork(info.wsEndpoint);
|
||||||
|
|
||||||
console.log([
|
console.log([
|
||||||
|
`- Endpoint: ${info.httpEndpoint}`,
|
||||||
`- View screen:`,
|
`- View screen:`,
|
||||||
` ${info.vncSession}`,
|
` ${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'));
|
].join('\n'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -130,6 +132,7 @@ async function buildPlaywrightImage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ContainerInfo {
|
interface ContainerInfo {
|
||||||
|
httpEndpoint: string;
|
||||||
wsEndpoint: string;
|
wsEndpoint: string;
|
||||||
vncSession: string;
|
vncSession: string;
|
||||||
}
|
}
|
||||||
|
|
@ -174,10 +177,14 @@ export async function containerInfo(): Promise<ContainerInfo|undefined> {
|
||||||
return undefined;
|
return undefined;
|
||||||
const wsEndpoint = containerUrlToHostUrl('ws://' + webSocketLine.substring(WS_LINE_PREFIX.length));
|
const wsEndpoint = containerUrlToHostUrl('ws://' + webSocketLine.substring(WS_LINE_PREFIX.length));
|
||||||
const vncSession = containerUrlToHostUrl(novncLine.substring(NOVNC_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);
|
const pwImage = await findDockerImage(VRT_IMAGE_NAME);
|
||||||
if (!pwImage) {
|
if (!pwImage) {
|
||||||
throw createStacklessError('\n' + utils.wrapInASCIIBox([
|
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({
|
await dockerApi.launchContainer({
|
||||||
imageId: pwImage.imageId,
|
imageId: pwImage.imageId,
|
||||||
name: VRT_CONTAINER_NAME,
|
name: VRT_CONTAINER_NAME,
|
||||||
autoRemove: true,
|
autoRemove: true,
|
||||||
ports: [5400, 7900],
|
ports: [
|
||||||
|
{ container: 5400, host: port },
|
||||||
|
{ container: 7900, host: 0 },
|
||||||
|
],
|
||||||
labels: {
|
labels: {
|
||||||
[VRT_CONTAINER_LABEL_NAME]: VRT_CONTAINER_LABEL_VALUE,
|
[VRT_CONTAINER_LABEL_NAME]: VRT_CONTAINER_LABEL_VALUE,
|
||||||
},
|
},
|
||||||
|
env,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Wait for the service to become available.
|
// Wait for the service to become available.
|
||||||
|
|
@ -274,6 +293,34 @@ function createStacklessError(message: string) {
|
||||||
return error;
|
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) {
|
export function addDockerCLI(program: Command) {
|
||||||
const dockerCommand = program.command('docker', { hidden: true })
|
const dockerCommand = program.command('docker', { hidden: true })
|
||||||
.description(`Manage Docker integration (EXPERIMENTAL)`);
|
.description(`Manage Docker integration (EXPERIMENTAL)`);
|
||||||
|
|
@ -291,9 +338,15 @@ export function addDockerCLI(program: Command) {
|
||||||
|
|
||||||
dockerCommand.command('start')
|
dockerCommand.command('start')
|
||||||
.description('start docker container')
|
.description('start docker container')
|
||||||
|
.option('--port <port>', 'port to start container on. Auto-pick by default')
|
||||||
.action(async function(options) {
|
.action(async function(options) {
|
||||||
try {
|
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) {
|
} catch (e) {
|
||||||
console.error(e.stack ? e : e.message);
|
console.error(e.stack ? e : e.message);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
|
@ -341,4 +394,50 @@ export function addDockerCLI(program: Command) {
|
||||||
.action(async function(options) {
|
.action(async function(options) {
|
||||||
await printDockerStatus();
|
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;
|
autoRemove: boolean;
|
||||||
command?: string[];
|
command?: string[];
|
||||||
labels?: Record<string, string>;
|
labels?: Record<string, string>;
|
||||||
ports?: Number[];
|
ports?: { container: number, host: number }[],
|
||||||
name?: string;
|
name?: string;
|
||||||
workingDir?: string;
|
workingDir?: string;
|
||||||
waitUntil?: 'not-running' | 'next-exit' | 'removed';
|
waitUntil?: 'not-running' | 'next-exit' | 'removed';
|
||||||
|
env?: { [key: string]: string | number | boolean | undefined };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function launchContainer(options: LaunchContainerOptions): Promise<string> {
|
export async function launchContainer(options: LaunchContainerOptions): Promise<string> {
|
||||||
const ExposedPorts: any = {};
|
const ExposedPorts: any = {};
|
||||||
const PortBindings: any = {};
|
const PortBindings: any = {};
|
||||||
for (const port of (options.ports ?? [])) {
|
for (const port of (options.ports ?? [])) {
|
||||||
ExposedPorts[`${port}/tcp`] = {};
|
ExposedPorts[`${port.container}/tcp`] = {};
|
||||||
PortBindings[`${port}/tcp`] = [{ HostPort: '0', HostIp: '127.0.0.1' }];
|
PortBindings[`${port.container}/tcp`] = [{ HostPort: port.host + '', HostIp: '127.0.0.1' }];
|
||||||
}
|
}
|
||||||
const container = await postJSON(`/containers/create` + (options.name ? '?name=' + options.name : ''), {
|
const container = await postJSON(`/containers/create` + (options.name ? '?name=' + options.name : ''), {
|
||||||
Cmd: options.command,
|
Cmd: options.command,
|
||||||
|
|
@ -84,6 +85,7 @@ export async function launchContainer(options: LaunchContainerOptions): Promise<
|
||||||
AttachStderr: true,
|
AttachStderr: true,
|
||||||
Image: options.imageId,
|
Image: options.imageId,
|
||||||
ExposedPorts,
|
ExposedPorts,
|
||||||
|
Env: dockerProtocolEnv(options.env),
|
||||||
HostConfig: {
|
HostConfig: {
|
||||||
Init: true,
|
Init: true,
|
||||||
AutoRemove: options.autoRemove,
|
AutoRemove: options.autoRemove,
|
||||||
|
|
@ -141,14 +143,18 @@ interface CommitContainerOptions {
|
||||||
env?: {[key: string]: string | number | boolean | undefined},
|
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) {
|
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}`, {
|
await postJSON(`/commit?container=${options.containerId}&repo=${options.repo}&tag=${options.tag}`, {
|
||||||
Entrypoint: options.entrypoint,
|
Entrypoint: options.entrypoint,
|
||||||
WorkingDir: options.workingDir,
|
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 { createPlaywright, DispatcherConnection, RootDispatcher, PlaywrightDispatcher } from '../server';
|
||||||
import { Browser } from '../server/browser';
|
import { Browser } from '../server/browser';
|
||||||
import { serverSideCallMetadata } from '../server/instrumentation';
|
import { serverSideCallMetadata } from '../server/instrumentation';
|
||||||
import { gracefullyCloseAll } from '../utils/processLauncher';
|
|
||||||
import { SocksProxy } from '../common/socksProxy';
|
import { SocksProxy } from '../common/socksProxy';
|
||||||
import { assert } from '../utils';
|
import { assert } from '../utils';
|
||||||
import type { LaunchOptions } from '../server/types';
|
import type { LaunchOptions } from '../server/types';
|
||||||
import { AndroidDevice } from '../server/android/android';
|
import { AndroidDevice } from '../server/android/android';
|
||||||
import { DebugControllerDispatcher } from '../server/dispatchers/debugControllerDispatcher';
|
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 = {
|
type Options = {
|
||||||
enableSocksProxy: boolean,
|
enableSocksProxy: boolean,
|
||||||
|
|
@ -38,6 +37,7 @@ type PreLaunched = {
|
||||||
playwright?: Playwright | undefined;
|
playwright?: Playwright | undefined;
|
||||||
browser?: Browser | undefined;
|
browser?: Browser | undefined;
|
||||||
androidDevice?: AndroidDevice | undefined;
|
androidDevice?: AndroidDevice | undefined;
|
||||||
|
networkTetheringSocksProxy?: SocksProxy | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
export class PlaywrightConnection {
|
export class PlaywrightConnection {
|
||||||
|
|
@ -90,17 +90,27 @@ export class PlaywrightConnection {
|
||||||
return await this._initLaunchBrowserMode(scope);
|
return await this._initLaunchBrowserMode(scope);
|
||||||
if (clientType === 'playwright')
|
if (clientType === 'playwright')
|
||||||
return await this._initPlaywrightConnectMode(scope);
|
return await this._initPlaywrightConnectMode(scope);
|
||||||
|
if (clientType === 'network-tethering')
|
||||||
|
return await this._initPlaywrightTetheringMode(scope);
|
||||||
throw new Error('Unsupported client type: ' + clientType);
|
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) {
|
private async _initPlaywrightConnectMode(scope: RootDispatcher) {
|
||||||
this._debugLog(`engaged playwright.connect mode`);
|
this._debugLog(`engaged playwright.connect mode`);
|
||||||
const playwright = createPlaywright('javascript');
|
const playwright = createPlaywright('javascript');
|
||||||
// Close all launched browsers on disconnect.
|
// 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);
|
return new PlaywrightDispatcher(scope, playwright, socksProxy);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -108,7 +118,7 @@ export class PlaywrightConnection {
|
||||||
this._debugLog(`engaged launch mode for "${this._options.browserName}"`);
|
this._debugLog(`engaged launch mode for "${this._options.browserName}"`);
|
||||||
|
|
||||||
const playwright = createPlaywright('javascript');
|
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);
|
const browser = await playwright[this._options.browserName as 'chromium'].launch(serverSideCallMetadata(), this._options.launchOptions);
|
||||||
|
|
||||||
this._cleanups.push(async () => {
|
this._cleanups.push(async () => {
|
||||||
|
|
@ -208,7 +218,14 @@ export class PlaywrightConnection {
|
||||||
return playwrightDispatcher;
|
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();
|
const socksProxy = new SocksProxy();
|
||||||
playwright.options.socksProxyPort = await socksProxy.listen(0);
|
playwright.options.socksProxyPort = await socksProxy.listen(0);
|
||||||
this._debugLog(`started socks proxy on port ${playwright.options.socksProxyPort}`);
|
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 type { LaunchOptions } from '../server/types';
|
||||||
import { ManualPromise } from '../utils/manualPromise';
|
import { ManualPromise } from '../utils/manualPromise';
|
||||||
import type { AndroidDevice } from '../server/android/android';
|
import type { AndroidDevice } from '../server/android/android';
|
||||||
|
import { SocksProxy } from '../common/socksProxy';
|
||||||
|
|
||||||
const debugLog = debug('pw:server');
|
const debugLog = debug('pw:server');
|
||||||
|
|
||||||
|
|
@ -39,15 +40,18 @@ function newLogger() {
|
||||||
type ServerOptions = {
|
type ServerOptions = {
|
||||||
path: string;
|
path: string;
|
||||||
maxConnections: number;
|
maxConnections: number;
|
||||||
enableSocksProxy: boolean;
|
|
||||||
preLaunchedBrowser?: Browser
|
preLaunchedBrowser?: Browser
|
||||||
preLaunchedAndroidDevice?: AndroidDevice
|
preLaunchedAndroidDevice?: AndroidDevice
|
||||||
|
browserProxyMode: 'client' | 'tether' | 'disabled',
|
||||||
|
ownedByTetherClient?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export class PlaywrightServer {
|
export class PlaywrightServer {
|
||||||
private _preLaunchedPlaywright: Playwright | undefined;
|
private _preLaunchedPlaywright: Playwright | undefined;
|
||||||
private _wsServer: WebSocketServer | undefined;
|
private _wsServer: WebSocketServer | undefined;
|
||||||
|
private _networkTetheringSocksProxy: SocksProxy | undefined;
|
||||||
private _options: ServerOptions;
|
private _options: ServerOptions;
|
||||||
|
private _networkTetheringClientTimeout: NodeJS.Timeout | undefined;
|
||||||
|
|
||||||
constructor(options: ServerOptions) {
|
constructor(options: ServerOptions) {
|
||||||
this._options = options;
|
this._options = options;
|
||||||
|
|
@ -87,20 +91,31 @@ export class PlaywrightServer {
|
||||||
resolve(wsEndpoint);
|
resolve(wsEndpoint);
|
||||||
}).on('error', reject);
|
}).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);
|
debugLog('Listening at ' + wsEndpoint);
|
||||||
|
if (this._options.ownedByTetherClient) {
|
||||||
|
this._networkTetheringClientTimeout = setTimeout(() => {
|
||||||
|
this.close();
|
||||||
|
}, 30_000);
|
||||||
|
}
|
||||||
|
|
||||||
this._wsServer = new wsServer({ server, path: this._options.path });
|
this._wsServer = new wsServer({ server, path: this._options.path });
|
||||||
const browserSemaphore = new Semaphore(this._options.maxConnections);
|
const browserSemaphore = new Semaphore(this._options.maxConnections);
|
||||||
const controllerSemaphore = new Semaphore(1);
|
const controllerSemaphore = new Semaphore(1);
|
||||||
const reuseBrowserSemaphore = new Semaphore(1);
|
const reuseBrowserSemaphore = new Semaphore(1);
|
||||||
|
const networkTetheringSemaphore = new Semaphore(1);
|
||||||
this._wsServer.on('connection', (ws, request) => {
|
this._wsServer.on('connection', (ws, request) => {
|
||||||
const url = new URL('http://localhost' + (request.url || ''));
|
const url = new URL('http://localhost' + (request.url || ''));
|
||||||
const browserHeader = request.headers['x-playwright-browser'];
|
const browserHeader = request.headers['x-playwright-browser'];
|
||||||
const browserName = url.searchParams.get('browser') || (Array.isArray(browserHeader) ? browserHeader[0] : browserHeader) || null;
|
const browserName = url.searchParams.get('browser') || (Array.isArray(browserHeader) ? browserHeader[0] : browserHeader) || null;
|
||||||
const proxyHeader = request.headers['x-playwright-proxy'];
|
const proxyHeader = request.headers['x-playwright-proxy'];
|
||||||
const proxyValue = url.searchParams.get('proxy') || (Array.isArray(proxyHeader) ? proxyHeader[0] : proxyHeader);
|
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'] || '';
|
const launchOptionsHeader = request.headers['x-playwright-launch-options'] || '';
|
||||||
let launchOptions: LaunchOptions = {};
|
let launchOptions: LaunchOptions = {};
|
||||||
|
|
@ -112,8 +127,8 @@ export class PlaywrightServer {
|
||||||
const log = newLogger();
|
const log = newLogger();
|
||||||
log(`serving connection: ${request.url}`);
|
log(`serving connection: ${request.url}`);
|
||||||
const isDebugControllerClient = !!request.headers['x-playwright-debug-controller'];
|
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 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 started in the legacy reuse-browser mode, create this._preLaunchedPlaywright.
|
||||||
// If we get a reuse-controller request, create this._preLaunchedPlaywright.
|
// If we get a reuse-controller request, create this._preLaunchedPlaywright.
|
||||||
|
|
@ -121,21 +136,42 @@ export class PlaywrightServer {
|
||||||
this.preLaunchedPlaywright();
|
this.preLaunchedPlaywright();
|
||||||
|
|
||||||
let clientType: ClientType = 'playwright';
|
let clientType: ClientType = 'playwright';
|
||||||
if (isDebugControllerClient)
|
let semaphore: Semaphore = browserSemaphore;
|
||||||
|
if (isNetworkTetheringClient) {
|
||||||
|
clientType = 'network-tethering';
|
||||||
|
semaphore = networkTetheringSemaphore;
|
||||||
|
} else if (isDebugControllerClient) {
|
||||||
clientType = 'controller';
|
clientType = 'controller';
|
||||||
else if (shouldReuseBrowser)
|
semaphore = controllerSemaphore;
|
||||||
|
} else if (shouldReuseBrowser) {
|
||||||
clientType = 'reuse-browser';
|
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';
|
clientType = 'pre-launched-browser';
|
||||||
else if (browserName)
|
semaphore = browserSemaphore;
|
||||||
|
} else if (browserName) {
|
||||||
clientType = 'launch-browser';
|
clientType = 'launch-browser';
|
||||||
|
semaphore = browserSemaphore;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clientType === 'network-tethering' && this._options.ownedByTetherClient)
|
||||||
|
clearTimeout(this._networkTetheringClientTimeout);
|
||||||
|
|
||||||
const connection = new PlaywrightConnection(
|
const connection = new PlaywrightConnection(
|
||||||
semaphore.aquire(),
|
semaphore.aquire(),
|
||||||
clientType, ws,
|
clientType, ws,
|
||||||
{ enableSocksProxy, browserName, launchOptions },
|
{ 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;
|
(ws as any)[kConnectionSymbol] = connection;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -146,6 +182,7 @@ export class PlaywrightServer {
|
||||||
const server = this._wsServer;
|
const server = this._wsServer;
|
||||||
if (!server)
|
if (!server)
|
||||||
return;
|
return;
|
||||||
|
await this._networkTetheringSocksProxy?.close();
|
||||||
debugLog('closing websocket server');
|
debugLog('closing websocket server');
|
||||||
const waitForClose = new Promise(f => server.close(f));
|
const waitForClose = new Promise(f => server.close(f));
|
||||||
// First disconnect all remaining clients.
|
// First disconnect all remaining clients.
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import EventEmitter from 'events';
|
import type EventEmitter from 'events';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import type * as channels from '@protocol/channels';
|
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 * as har from '@trace/har';
|
||||||
import type { HeadersArray } from '../types';
|
import type { HeadersArray } from '../types';
|
||||||
import { JsonPipeDispatcher } from '../dispatchers/jsonPipeDispatcher';
|
import { JsonPipeDispatcher } from '../dispatchers/jsonPipeDispatcher';
|
||||||
import * as socks from '../../common/socksProxy';
|
|
||||||
import { WebSocketTransport } from '../transport';
|
import { WebSocketTransport } from '../transport';
|
||||||
|
import { SocksInterceptor } from '../socksInterceptor';
|
||||||
import type { CallMetadata } from '../instrumentation';
|
import type { CallMetadata } from '../instrumentation';
|
||||||
import { getUserAgent } from '../../common/userAgent';
|
import { getUserAgent } from '../../common/userAgent';
|
||||||
import type { Progress } from '../progress';
|
import type { Progress } from '../progress';
|
||||||
import { ProgressController } from '../progress';
|
import { ProgressController } from '../progress';
|
||||||
import { findValidator, ValidationError } from '../../protocol/validator';
|
|
||||||
import type { ValidatorContext } from '../../protocol/validator';
|
|
||||||
import { fetchData } from '../../common/netUtils';
|
import { fetchData } from '../../common/netUtils';
|
||||||
import type { HTTPRequestParams } from '../../common/netUtils';
|
import type { HTTPRequestParams } from '../../common/netUtils';
|
||||||
import type http from 'http';
|
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 wsEndpoint = await urlToWSEndpoint(progress, params.wsEndpoint);
|
||||||
|
|
||||||
const transport = await WebSocketTransport.connect(progress, wsEndpoint, paramsHeaders, true);
|
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);
|
const pipe = new JsonPipeDispatcher(this);
|
||||||
transport.onmessage = json => {
|
transport.onmessage = json => {
|
||||||
if (json.method === '__create__' && json.params.type === 'SocksSupport')
|
if (socksInterceptor.interceptMessage(json))
|
||||||
socksInterceptor = new SocksInterceptor(transport, params.socksProxyRedirectPortForTest, json.params.guid);
|
|
||||||
if (socksInterceptor?.interceptMessage(json))
|
|
||||||
return;
|
return;
|
||||||
const cb = () => {
|
const cb = () => {
|
||||||
try {
|
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 {
|
function countMatchingHeaders(harHeaders: har.Header[], headers: HeadersArray): number {
|
||||||
const set = new Set(headers.map(h => h.name.toLowerCase() + ':' + h.value));
|
const set = new Set(headers.map(h => h.name.toLowerCase() + ':' + h.value));
|
||||||
let matches = 0;
|
let matches = 0;
|
||||||
|
|
@ -383,15 +323,11 @@ function countMatchingHeaders(harHeaders: har.Header[], headers: HeadersArray):
|
||||||
return matches;
|
return matches;
|
||||||
}
|
}
|
||||||
|
|
||||||
function tChannelForSocks(names: '*' | string[], arg: any, path: string, context: ValidatorContext) {
|
export async function urlToWSEndpoint(progress: Progress|undefined, endpointURL: string): Promise<string> {
|
||||||
throw new ValidationError(`${path}: channels are not expected in SocksSupport`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function urlToWSEndpoint(progress: Progress, endpointURL: string): Promise<string> {
|
|
||||||
if (endpointURL.startsWith('ws'))
|
if (endpointURL.startsWith('ws'))
|
||||||
return endpointURL;
|
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);
|
const fetchUrl = new URL(endpointURL);
|
||||||
if (!fetchUrl.pathname.endsWith('/'))
|
if (!fetchUrl.pathname.endsWith('/'))
|
||||||
fetchUrl.pathname += '/';
|
fetchUrl.pathname += '/';
|
||||||
|
|
@ -399,13 +335,13 @@ async function urlToWSEndpoint(progress: Progress, endpointURL: string): Promise
|
||||||
const json = await fetchData({
|
const json = await fetchData({
|
||||||
url: fetchUrl.toString(),
|
url: fetchUrl.toString(),
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
timeout: progress.timeUntilDeadline(),
|
timeout: progress?.timeUntilDeadline() ?? 30_000,
|
||||||
headers: { 'User-Agent': getUserAgent() },
|
headers: { 'User-Agent': getUserAgent() },
|
||||||
}, async (params: HTTPRequestParams, response: http.IncomingMessage) => {
|
}, async (params: HTTPRequestParams, response: http.IncomingMessage) => {
|
||||||
return new Error(`Unexpected status ${response.statusCode} when connecting to ${fetchUrl.toString()}.\n` +
|
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://.`);
|
`This does not look like a Playwright server, try connecting via ws://.`);
|
||||||
});
|
});
|
||||||
progress.throwIfAborted();
|
progress?.throwIfAborted();
|
||||||
|
|
||||||
const wsUrl = new URL(endpointURL);
|
const wsUrl = new URL(endpointURL);
|
||||||
let wsEndpointPath = JSON.parse(json).wsEndpointPath;
|
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 {
|
export class WebSocketTransport implements ConnectionTransport {
|
||||||
private _ws: WebSocket;
|
private _ws: WebSocket;
|
||||||
private _progress: Progress;
|
private _progress?: Progress;
|
||||||
|
|
||||||
onmessage?: (message: ProtocolResponse) => void;
|
onmessage?: (message: ProtocolResponse) => void;
|
||||||
onclose?: () => void;
|
onclose?: () => void;
|
||||||
readonly wsEndpoint: string;
|
readonly wsEndpoint: string;
|
||||||
|
|
||||||
static async connect(progress: Progress, url: string, headers?: { [key: string]: string; }, followRedirects?: boolean): Promise<WebSocketTransport> {
|
static async connect(progress: (Progress|undefined), url: string, headers?: { [key: string]: string; }, followRedirects?: boolean): Promise<WebSocketTransport> {
|
||||||
progress.log(`<ws connecting> ${url}`);
|
progress?.log(`<ws connecting> ${url}`);
|
||||||
const transport = new WebSocketTransport(progress, url, headers, followRedirects);
|
const transport = new WebSocketTransport(progress, url, headers, followRedirects);
|
||||||
let success = false;
|
let success = false;
|
||||||
progress.cleanupWhenAborted(async () => {
|
progress?.cleanupWhenAborted(async () => {
|
||||||
if (!success)
|
if (!success)
|
||||||
await transport.closeAndWait().catch(e => null);
|
await transport.closeAndWait().catch(e => null);
|
||||||
});
|
});
|
||||||
await new Promise<WebSocketTransport>((fulfill, reject) => {
|
await new Promise<WebSocketTransport>((fulfill, reject) => {
|
||||||
transport._ws.on('open', async () => {
|
transport._ws.on('open', async () => {
|
||||||
progress.log(`<ws connected> ${url}`);
|
progress?.log(`<ws connected> ${url}`);
|
||||||
fulfill(transport);
|
fulfill(transport);
|
||||||
});
|
});
|
||||||
transport._ws.on('error', event => {
|
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));
|
reject(new Error('WebSocket error: ' + event.message));
|
||||||
transport._ws.close();
|
transport._ws.close();
|
||||||
});
|
});
|
||||||
|
|
@ -78,7 +78,7 @@ export class WebSocketTransport implements ConnectionTransport {
|
||||||
response.on('data', chunk => chunks.push(chunk));
|
response.on('data', chunk => chunks.push(chunk));
|
||||||
response.on('close', () => {
|
response.on('close', () => {
|
||||||
const error = chunks.length ? `${errorPrefix}\n${Buffer.concat(chunks)}` : errorPrefix;
|
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));
|
reject(new Error('WebSocket error: ' + error));
|
||||||
transport._ws.close();
|
transport._ws.close();
|
||||||
});
|
});
|
||||||
|
|
@ -88,13 +88,13 @@ export class WebSocketTransport implements ConnectionTransport {
|
||||||
return transport;
|
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.wsEndpoint = url;
|
||||||
this._ws = new ws(url, [], {
|
this._ws = new ws(url, [], {
|
||||||
perMessageDeflate: false,
|
perMessageDeflate: false,
|
||||||
maxPayload: 256 * 1024 * 1024, // 256Mb,
|
maxPayload: 256 * 1024 * 1024, // 256Mb,
|
||||||
// Prevent internal http client error when passing negative timeout.
|
// Prevent internal http client error when passing negative timeout.
|
||||||
handshakeTimeout: Math.max(progress.timeUntilDeadline(), 1),
|
handshakeTimeout: Math.max(progress?.timeUntilDeadline() ?? 30_000, 1),
|
||||||
headers,
|
headers,
|
||||||
followRedirects,
|
followRedirects,
|
||||||
});
|
});
|
||||||
|
|
@ -117,12 +117,12 @@ export class WebSocketTransport implements ConnectionTransport {
|
||||||
});
|
});
|
||||||
|
|
||||||
this._ws.addEventListener('close', event => {
|
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)
|
if (this.onclose)
|
||||||
this.onclose.call(null);
|
this.onclose.call(null);
|
||||||
});
|
});
|
||||||
// Prevent Error: read ECONNRESET.
|
// 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) {
|
send(message: ProtocolRequest) {
|
||||||
|
|
@ -130,7 +130,7 @@ export class WebSocketTransport implements ConnectionTransport {
|
||||||
}
|
}
|
||||||
|
|
||||||
close() {
|
close() {
|
||||||
this._progress && this._progress.log(`<ws disconnecting> ${this._ws.url}`);
|
this._progress?.log(`<ws disconnecting> ${this._ws.url}`);
|
||||||
this._ws.close();
|
this._ws.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@
|
||||||
import type { TestRunnerPlugin } from '.';
|
import type { TestRunnerPlugin } from '.';
|
||||||
import type { FullConfig, Reporter, Suite } from '../../types/testReporter';
|
import type { FullConfig, Reporter, Suite } from '../../types/testReporter';
|
||||||
import { colors } from 'playwright-core/lib/utilsBundle';
|
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 = {
|
export const dockerPlugin: TestRunnerPlugin = {
|
||||||
name: 'playwright:docker',
|
name: 'playwright:docker',
|
||||||
|
|
@ -26,22 +26,13 @@ export const dockerPlugin: TestRunnerPlugin = {
|
||||||
if (!process.env.PLAYWRIGHT_DOCKER)
|
if (!process.env.PLAYWRIGHT_DOCKER)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
const print = (text: string) => reporter.onStdOut?.(text);
|
|
||||||
const println = (text: string) => reporter.onStdOut?.(text + '\n');
|
const println = (text: string) => reporter.onStdOut?.(text + '\n');
|
||||||
|
|
||||||
println(colors.dim('Using docker container to run browsers.'));
|
println(colors.dim('Using docker container to run browsers.'));
|
||||||
await checkDockerEngineIsRunningOrDie();
|
await checkDockerEngineIsRunningOrDie();
|
||||||
let info = await containerInfo();
|
const info = await containerInfo();
|
||||||
if (!info) {
|
if (!info)
|
||||||
print(colors.dim(`Starting docker container... `));
|
throw new Error('ERROR: please launch docker container separately!');
|
||||||
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'));
|
|
||||||
}
|
|
||||||
println(colors.dim(`View screen: ${info.vncSession}`));
|
println(colors.dim(`View screen: ${info.vncSession}`));
|
||||||
println('');
|
println('');
|
||||||
process.env.PW_TEST_CONNECT_WS_ENDPOINT = info.wsEndpoint;
|
process.env.PW_TEST_CONNECT_WS_ENDPOINT = info.wsEndpoint;
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@
|
||||||
|
|
||||||
import type { TestType, Fixtures } from '@playwright/test';
|
import type { TestType, Fixtures } from '@playwright/test';
|
||||||
import { test } from '@playwright/test';
|
import { test } from '@playwright/test';
|
||||||
import type { CommonFixtures } from './commonFixtures';
|
import type { CommonFixtures, CommonWorkerFixtures } from './commonFixtures';
|
||||||
import { commonFixtures } from './commonFixtures';
|
import { commonFixtures } from './commonFixtures';
|
||||||
import type { ServerFixtures, ServerWorkerOptions } from './serverFixtures';
|
import type { ServerFixtures, ServerWorkerOptions } from './serverFixtures';
|
||||||
import { serverFixtures } from './serverFixtures';
|
import { serverFixtures } from './serverFixtures';
|
||||||
|
|
@ -36,7 +36,7 @@ export const baseTest = base
|
||||||
._extendTest(coverageTest)
|
._extendTest(coverageTest)
|
||||||
._extendTest(platformTest)
|
._extendTest(platformTest)
|
||||||
._extendTest(testModeTest)
|
._extendTest(testModeTest)
|
||||||
.extend<CommonFixtures>(commonFixtures)
|
.extend<CommonFixtures, CommonWorkerFixtures>(commonFixtures)
|
||||||
.extend<ServerFixtures, ServerWorkerOptions>(serverFixtures)
|
.extend<ServerFixtures, ServerWorkerOptions>(serverFixtures)
|
||||||
.extend<{}, { _snapshotSuffix: string }>({
|
.extend<{}, { _snapshotSuffix: string }>({
|
||||||
_snapshotSuffix: ['', { scope: 'worker' }],
|
_snapshotSuffix: ['', { scope: 'worker' }],
|
||||||
|
|
|
||||||
|
|
@ -115,7 +115,11 @@ export type CommonFixtures = {
|
||||||
waitForPort: (port: number) => Promise<void>;
|
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) => {
|
childProcess: async ({}, use, testInfo) => {
|
||||||
const processes: TestChildProcess[] = [];
|
const processes: TestChildProcess[] = [];
|
||||||
await use(params => {
|
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) => {
|
waitForPort: async ({}, use) => {
|
||||||
const token = { canceled: false };
|
const token = { canceled: false };
|
||||||
await use(async port => {
|
await use(async port => {
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import { test, expect } from './npmTest';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { TestServer } from '../../utils/testserver';
|
import { TestServer } from '../../utils/testserver';
|
||||||
|
|
||||||
|
|
||||||
// Skipping docker tests on CI on non-linux since GHA does not have
|
// Skipping docker tests on CI on non-linux since GHA does not have
|
||||||
// Docker engine installed on macOS and Windows.
|
// Docker engine installed on macOS and Windows.
|
||||||
test.skip(() => process.env.CI && process.platform !== 'linux');
|
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');
|
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,
|
expectToExitWithError: true,
|
||||||
env: { PLAYWRIGHT_DOCKER: '1' },
|
|
||||||
});
|
});
|
||||||
expect(result).toContain('npx playwright docker build');
|
expect(result).toContain('npx playwright docker build');
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe('installed image', () => {
|
test.describe('installed image', () => {
|
||||||
test.beforeAll(async ({ exec }) => {
|
test.beforeAll(async ({ exec, daemonProcess, waitForPort }) => {
|
||||||
await exec('npx playwright docker build', {
|
await exec('npx playwright docker build', {
|
||||||
env: { PWTEST_DOCKER_BASE_IMAGE: 'playwright:installation-tests-focal' },
|
env: { PWTEST_DOCKER_BASE_IMAGE: 'playwright:installation-tests-focal' },
|
||||||
cwd: path.join(__dirname, '..', '..'),
|
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 }) => {
|
test.afterAll(async ({ exec }) => {
|
||||||
await exec('npx playwright docker delete-image', {
|
await exec('npx playwright docker delete-image', {
|
||||||
cwd: path.join(__dirname, '..', '..'),
|
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('npm i --foreground-scripts @playwright/test');
|
||||||
await exec('npx playwright docker stop');
|
const result = await exec('npx playwright test docker.spec.js --grep platform --browser all', {
|
||||||
const result = await exec('npx playwright test docker.spec.js --grep platform', {
|
|
||||||
env: { PLAYWRIGHT_DOCKER: '1' },
|
env: { PLAYWRIGHT_DOCKER: '1' },
|
||||||
});
|
});
|
||||||
expect(result).toContain('@chromium Linux');
|
expect(result).toContain('@chromium Linux');
|
||||||
|
expect(result).toContain('@webkit Linux');
|
||||||
|
expect(result).toContain('@firefox Linux');
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe('running container', () => {
|
test('all browsers work headed', async ({ exec }) => {
|
||||||
test.beforeAll(async ({ exec }) => {
|
await exec('npm i --foreground-scripts @playwright/test');
|
||||||
await exec('npx playwright docker start', {
|
{
|
||||||
cwd: path.join(__dirname, '..', '..'),
|
const result = await exec(`npx playwright test docker.spec.js --headed --grep userAgent --browser chromium`, {
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
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', {
|
|
||||||
env: { PLAYWRIGHT_DOCKER: '1' },
|
env: { PLAYWRIGHT_DOCKER: '1' },
|
||||||
});
|
});
|
||||||
expect(result).toContain('@chromium Linux');
|
expect(result).toContain('@chromium');
|
||||||
expect(result).toContain('@webkit Linux');
|
expect(result).not.toContain('Headless');
|
||||||
expect(result).toContain('@firefox Linux');
|
expect(result).toContain(' Chrome/');
|
||||||
});
|
}
|
||||||
|
{
|
||||||
test('supports PLAYWRIGHT_DOCKER env variable', async ({ exec }) => {
|
const result = await exec(`npx playwright test docker.spec.js --headed --grep userAgent --browser webkit`, {
|
||||||
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,
|
|
||||||
env: { PLAYWRIGHT_DOCKER: '1' },
|
env: { PLAYWRIGHT_DOCKER: '1' },
|
||||||
});
|
});
|
||||||
await expect(path.join(tmpWorkspace, '__screenshots__', 'firefox', 'docker.spec.js', 'img.png')).toExistOnFS();
|
expect(result).toContain('@webkit');
|
||||||
await expect(path.join(tmpWorkspace, '__screenshots__', 'chromium', 'docker.spec.js', 'img.png')).toExistOnFS();
|
expect(result).toContain(' Version/');
|
||||||
await expect(path.join(tmpWorkspace, '__screenshots__', 'webkit', 'docker.spec.js', 'img.png')).toExistOnFS();
|
}
|
||||||
});
|
{
|
||||||
|
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 }) => {
|
test('screenshots should use __screenshots__ folder', async ({ exec, tmpWorkspace }) => {
|
||||||
await exec('npm i --foreground-scripts @playwright/test');
|
await exec('npm i --foreground-scripts @playwright/test');
|
||||||
const TEST_PORT = 8425;
|
await exec('npx playwright test docker.spec.js --grep screenshot --browser all', {
|
||||||
const server = await TestServer.create(tmpWorkspace, TEST_PORT);
|
expectToExitWithError: true,
|
||||||
server.setRoute('/', (request, response) => {
|
env: { PLAYWRIGHT_DOCKER: '1' },
|
||||||
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');
|
|
||||||
});
|
});
|
||||||
|
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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,8 @@ import path from 'path';
|
||||||
import debugLogger from 'debug';
|
import debugLogger from 'debug';
|
||||||
import { Registry } from './registry';
|
import { Registry } from './registry';
|
||||||
import { spawnAsync } from './spawnAsync';
|
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');
|
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<string, string>, message?: string, expectToExitWithError?: boolean };
|
export type ExecOptions = { cwd?: string, env?: Record<string, string>, message?: string, expectToExitWithError?: boolean };
|
||||||
export type ArgsOrOptions = [] | [...string[]] | [...string[], ExecOptions] | [ExecOptions];
|
export type ArgsOrOptions = [] | [...string[]] | [...string[], ExecOptions] | [ExecOptions];
|
||||||
export const test = _test.extend<{
|
|
||||||
_auto: void,
|
|
||||||
tmpWorkspace: string,
|
|
||||||
nodeMajorVersion: number,
|
|
||||||
installedSoftwareOnDisk: (registryPath?: string) => Promise<string[]>;
|
|
||||||
writeFiles: (nameToContents: Record<string, string>) => Promise<void>,
|
|
||||||
exec: (cmd: string, ...argsAndOrOptions: ArgsOrOptions) => Promise<string>
|
|
||||||
tsc: (...argsAndOrOptions: ArgsOrOptions) => Promise<string>,
|
|
||||||
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<string, string>) => {
|
|
||||||
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<string[]>;
|
||||||
|
writeFiles: (nameToContents: Record<string, string>) => Promise<void>,
|
||||||
|
exec: (cmd: string, ...argsAndOrOptions: ArgsOrOptions) => Promise<string>
|
||||||
|
tsc: (...argsAndOrOptions: ArgsOrOptions) => Promise<string>,
|
||||||
|
registry: Registry,
|
||||||
|
};
|
||||||
|
|
||||||
let result!: Awaited<ReturnType<typeof spawnAsync>>;
|
export const test = _test
|
||||||
await test.step(`exec: ${[cmd, ...args].join(' ')}`, async () => {
|
.extend<CommonFixtures, CommonWorkerFixtures>(commonFixtures)
|
||||||
result = await spawnAsync(cmd, args, {
|
.extend<NPMTestFixtures>({
|
||||||
shell: true,
|
_auto: [async ({ tmpWorkspace, exec }, use) => {
|
||||||
cwd: options.cwd ?? tmpWorkspace,
|
await exec('npm init -y');
|
||||||
// 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.
|
const sourceDir = path.join(__dirname, 'fixture-scripts');
|
||||||
env: {
|
const contents = await fs.promises.readdir(sourceDir);
|
||||||
'PATH': process.env.PATH,
|
await Promise.all(contents.map(f => fs.promises.copyFile(path.join(sourceDir, f), path.join(tmpWorkspace, f))));
|
||||||
'DISPLAY': process.env.DISPLAY,
|
await use();
|
||||||
'XAUTHORITY': process.env.XAUTHORITY,
|
}, {
|
||||||
'PLAYWRIGHT_BROWSERS_PATH': path.join(tmpWorkspace, 'browsers'),
|
auto: true,
|
||||||
'npm_config_cache': testInfo.outputPath('npm_cache'),
|
}],
|
||||||
'npm_config_registry': registry.url(),
|
nodeMajorVersion: async ({}, use) => {
|
||||||
'npm_config_prefix': testInfo.outputPath('npm_global'),
|
await use(+process.versions.node.split('.')[0]);
|
||||||
...options.env,
|
},
|
||||||
}
|
writeFiles: async ({ tmpWorkspace }, use) => {
|
||||||
});
|
await use(async (nameToContents: Record<string, string>) => {
|
||||||
});
|
for (const [name, contents] of Object.entries(nameToContents))
|
||||||
|
await fs.promises.writeFile(path.join(tmpWorkspace, name), contents);
|
||||||
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));
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
},
|
||||||
|
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<ReturnType<typeof spawnAsync>>;
|
||||||
|
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 };
|
export { expect };
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue