feat: introduce docker integration for Playwright Test (#9599)
This commit is contained in:
parent
ba57be99a9
commit
983cfde4d4
|
|
@ -20,7 +20,9 @@
|
||||||
"require": "./index.js"
|
"require": "./index.js"
|
||||||
},
|
},
|
||||||
"./cli": "./cli.js",
|
"./cli": "./cli.js",
|
||||||
|
"./src/grid/gridServer": "./lib/grid/gridServer.js",
|
||||||
"./src/grid/gridClient": "./lib/grid/gridClient.js",
|
"./src/grid/gridClient": "./lib/grid/gridClient.js",
|
||||||
|
"./src/grid/dockerGridFactory": "./lib/grid/dockerGridFactory.js",
|
||||||
"./src/utils/async": "./lib/utils/async.js",
|
"./src/utils/async": "./lib/utils/async.js",
|
||||||
"./src/utils/httpServer": "./lib/utils/httpServer.js",
|
"./src/utils/httpServer": "./lib/utils/httpServer.js",
|
||||||
"./src/utils/processLauncher": "./lib/utils/processLauncher.js",
|
"./src/utils/processLauncher": "./lib/utils/processLauncher.js",
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ import { spawn } from 'child_process';
|
||||||
import { registry, Executable } from '../utils/registry';
|
import { registry, Executable } from '../utils/registry';
|
||||||
import { spawnAsync, getPlaywrightVersion } from '../utils/utils';
|
import { spawnAsync, getPlaywrightVersion } from '../utils/utils';
|
||||||
import { launchGridAgent } from '../grid/gridAgent';
|
import { launchGridAgent } from '../grid/gridAgent';
|
||||||
import { launchGridServer } from '../grid/gridServer';
|
import { GridServer, GridFactory } from '../grid/gridServer';
|
||||||
|
|
||||||
const packageJSON = require('../../package.json');
|
const packageJSON = require('../../package.json');
|
||||||
|
|
||||||
|
|
@ -571,3 +571,22 @@ function commandWithOpenOptions(command: string, description: string, options: a
|
||||||
.option('--user-agent <ua string>', 'specify user agent string')
|
.option('--user-agent <ua string>', 'specify user agent string')
|
||||||
.option('--viewport-size <size>', 'specify browser viewport size in pixels, for example "1280, 720"');
|
.option('--viewport-size <size>', 'specify browser viewport size in pixels, for example "1280, 720"');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function launchGridServer(factoryPathOrPackageName: string, port: number, authToken: string|undefined): Promise<void> {
|
||||||
|
if (!factoryPathOrPackageName)
|
||||||
|
factoryPathOrPackageName = path.join('..', 'grid', 'simpleGridFactory');
|
||||||
|
let factory;
|
||||||
|
try {
|
||||||
|
factory = require(path.resolve(factoryPathOrPackageName));
|
||||||
|
} catch (e) {
|
||||||
|
factory = require(factoryPathOrPackageName);
|
||||||
|
}
|
||||||
|
if (factory && typeof factory === 'object' && ('default' in factory))
|
||||||
|
factory = factory['default'];
|
||||||
|
if (!factory || !factory.launch || typeof factory.launch !== 'function')
|
||||||
|
throw new Error('factory does not export `launch` method');
|
||||||
|
factory.name = factory.name || factoryPathOrPackageName;
|
||||||
|
const gridServer = new GridServer(factory as GridFactory, authToken);
|
||||||
|
await gridServer.start(port);
|
||||||
|
console.log('Grid server is running at ' + gridServer.urlPrefix());
|
||||||
|
}
|
||||||
|
|
|
||||||
146
packages/playwright-core/src/grid/dockerGridFactory.ts
Normal file
146
packages/playwright-core/src/grid/dockerGridFactory.ts
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
/**
|
||||||
|
* 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 http from 'http';
|
||||||
|
import os from 'os';
|
||||||
|
import { GridAgentLaunchOptions, GridFactory } from './gridServer';
|
||||||
|
import * as utils from '../utils/utils';
|
||||||
|
|
||||||
|
const dockerFactory: GridFactory = {
|
||||||
|
name: 'Agents launched inside Docker container',
|
||||||
|
capacity: Infinity,
|
||||||
|
launchTimeout: 30000,
|
||||||
|
retireTimeout: Infinity,
|
||||||
|
launch: async (options: GridAgentLaunchOptions) => {
|
||||||
|
const { vncUrl } = await launchDockerGridAgent(options.agentId, options.gridURL);
|
||||||
|
/* eslint-disable no-console */
|
||||||
|
console.log(``);
|
||||||
|
console.log(`✨ Running browsers inside docker container ✨`);
|
||||||
|
console.log(`- look inside: ${vncUrl}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default dockerFactory;
|
||||||
|
|
||||||
|
async function launchDockerGridAgent(agentId: string, gridURL: string): Promise<{vncUrl: string }> {
|
||||||
|
const gridPort = new URL(gridURL).port || '80';
|
||||||
|
const images = await getJSON('/images/json');
|
||||||
|
let imageName = process.env.PWTEST_IMAGE_NAME;
|
||||||
|
if (!imageName) {
|
||||||
|
const packageJson = require('../../package.json');
|
||||||
|
imageName = `mcr.microsoft.com/playwright:v${packageJson.version}-focal`;
|
||||||
|
}
|
||||||
|
const pwImage = images.find((image: any) => image.RepoTags.includes(imageName));
|
||||||
|
if (!pwImage) {
|
||||||
|
throw new Error(`\n` + utils.wrapInASCIIBox([
|
||||||
|
`Failed to find ${imageName} docker image.`,
|
||||||
|
`Please pull docker image with the following command:`,
|
||||||
|
``,
|
||||||
|
` npx playwight install docker-image`,
|
||||||
|
``,
|
||||||
|
`<3 Playwright Team`,
|
||||||
|
].join('\n'), 1));
|
||||||
|
}
|
||||||
|
const Env = [
|
||||||
|
'PW_SOCKS_PROXY_PORT=1', // Enable port forwarding over PlaywrightClient
|
||||||
|
];
|
||||||
|
const forwardIfDefined = (envName: string) => {
|
||||||
|
if (process.env[envName])
|
||||||
|
Env.push(`CI=${process.env[envName]}`);
|
||||||
|
};
|
||||||
|
forwardIfDefined('CI');
|
||||||
|
forwardIfDefined('PWDEBUG');
|
||||||
|
forwardIfDefined('DEBUG');
|
||||||
|
forwardIfDefined('DEBUG_FILE');
|
||||||
|
forwardIfDefined('SELENIUM_REMOTE_URL');
|
||||||
|
|
||||||
|
const container = await postJSON('/containers/create', {
|
||||||
|
Env,
|
||||||
|
WorkingDir: '/ms-playwright-agent',
|
||||||
|
Cmd: [ 'bash', 'start_agent.sh', agentId, `http://host.docker.internal:${gridPort}` ],
|
||||||
|
AttachStdout: true,
|
||||||
|
AttachStderr: true,
|
||||||
|
Image: pwImage.Id,
|
||||||
|
ExposedPorts: {
|
||||||
|
'7900/tcp': { }
|
||||||
|
},
|
||||||
|
HostConfig: {
|
||||||
|
Init: true,
|
||||||
|
AutoRemove: true,
|
||||||
|
ShmSize: 2 * 1024 * 1024 * 1024,
|
||||||
|
ExtraHosts: process.platform === 'linux' ? [
|
||||||
|
'host.docker.internal:host-gateway', // Enable host.docker.internal on Linux.
|
||||||
|
] : [],
|
||||||
|
PortBindings: {
|
||||||
|
'7900/tcp': [{ HostPort: '0' }]
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await postJSON(`/containers/${container.Id}/start`);
|
||||||
|
const info = await getJSON(`/containers/${container.Id}/json`);
|
||||||
|
const vncPort = info?.NetworkSettings?.Ports['7900/tcp'];
|
||||||
|
return {
|
||||||
|
vncUrl: `http://localhost:${vncPort[0].HostPort}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getJSON(url: string): Promise<any> {
|
||||||
|
const result = await callDockerAPI('get', url);
|
||||||
|
if (!result)
|
||||||
|
return result;
|
||||||
|
return JSON.parse(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postJSON(url: string, json: any = undefined) {
|
||||||
|
const result = await callDockerAPI('post', url, json ? JSON.stringify(json) : undefined);
|
||||||
|
if (!result)
|
||||||
|
return result;
|
||||||
|
return JSON.parse(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
function callDockerAPI(method: 'post'|'get', url: string, body: Buffer|string|undefined = undefined): Promise<string|null> {
|
||||||
|
const dockerSocket = os.platform() === 'win32' ? '\\\\.\\pipe\\docker_engine' : '/var/run/docker.sock';
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const request = http.request({
|
||||||
|
socketPath: dockerSocket,
|
||||||
|
path: url,
|
||||||
|
method,
|
||||||
|
}, (response: http.IncomingMessage) => {
|
||||||
|
let body = '';
|
||||||
|
response.on('data', function(chunk){
|
||||||
|
body += chunk;
|
||||||
|
});
|
||||||
|
response.on('end', function(){
|
||||||
|
if (!response.statusCode || response.statusCode < 200 || response.statusCode >= 300) {
|
||||||
|
console.error(`ERROR ${method} ${url}`, response.statusCode, body);
|
||||||
|
resolve(null);
|
||||||
|
} else {
|
||||||
|
resolve(body);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
request.on('error', function(e){
|
||||||
|
console.error('Error fetching json: ' + e);
|
||||||
|
resolve(null);
|
||||||
|
});
|
||||||
|
if (body) {
|
||||||
|
request.setHeader('Content-Type', 'application/json');
|
||||||
|
request.setHeader('Content-Length', body.length);
|
||||||
|
request.write(body);
|
||||||
|
}
|
||||||
|
request.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -15,7 +15,6 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import debug from 'debug';
|
import debug from 'debug';
|
||||||
import path from 'path';
|
|
||||||
import assert from 'assert';
|
import assert from 'assert';
|
||||||
import { EventEmitter } from 'events';
|
import { EventEmitter } from 'events';
|
||||||
import { URL } from 'url';
|
import { URL } from 'url';
|
||||||
|
|
@ -32,7 +31,8 @@ export type GridAgentLaunchOptions = {
|
||||||
export type GridFactory = {
|
export type GridFactory = {
|
||||||
name?: string,
|
name?: string,
|
||||||
capacity?: number,
|
capacity?: number,
|
||||||
timeout?: number,
|
launchTimeout?: number,
|
||||||
|
retireTimeout?: number,
|
||||||
launch: (launchOptions: GridAgentLaunchOptions) => Promise<void>,
|
launch: (launchOptions: GridAgentLaunchOptions) => Promise<void>,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -44,10 +44,10 @@ type ErrorCode = {
|
||||||
const WSErrors = {
|
const WSErrors = {
|
||||||
NO_ERROR: { code: 1000, reason: '' },
|
NO_ERROR: { code: 1000, reason: '' },
|
||||||
AUTH_FAILED: { code: 1008, reason: 'Grid authentication failed' },
|
AUTH_FAILED: { code: 1008, reason: 'Grid authentication failed' },
|
||||||
AGENT_CREATION_FAILED: { code: 1013, reason: 'Grid agent creationg failed' },
|
AGENT_CREATION_FAILED: { code: 1013, reason: 'Grid agent creation failed' },
|
||||||
AGENT_NOT_FOUND: { code: 1013, reason: 'Grid agent registration failed - agent with given ID not found' },
|
AGENT_NOT_FOUND: { code: 1013, reason: 'Grid agent registration failed - agent with given ID not found' },
|
||||||
AGENT_NOT_CONNECTED: { code: 1013, reason: 'Grid worker registration failed - agent has unsupported status' },
|
AGENT_NOT_CONNECTED: { code: 1013, reason: 'Grid worker registration failed - agent has unsupported status' },
|
||||||
AGENT_CREATION_TIMED_OUT: { code: 1013, reason: 'Grid agent creationg timed out' },
|
AGENT_CREATION_TIMED_OUT: { code: 1013, reason: 'Grid agent creation timed out' },
|
||||||
AGENT_RETIRED: { code: 1000, reason: 'Grid agent was retired' },
|
AGENT_RETIRED: { code: 1000, reason: 'Grid agent was retired' },
|
||||||
CLIENT_SOCKET_ERROR: { code: 1011, reason: 'Grid client socket error' },
|
CLIENT_SOCKET_ERROR: { code: 1011, reason: 'Grid client socket error' },
|
||||||
WORKER_SOCKET_ERROR: { code: 1011, reason: 'Grid worker socket error' },
|
WORKER_SOCKET_ERROR: { code: 1011, reason: 'Grid worker socket error' },
|
||||||
|
|
@ -102,16 +102,18 @@ class GridAgent extends EventEmitter {
|
||||||
readonly _workers = new Map<string, GridWorker>();
|
readonly _workers = new Map<string, GridWorker>();
|
||||||
private _status: AgentStatus = 'none';
|
private _status: AgentStatus = 'none';
|
||||||
private _workersWaitingForAgentConnected: GridWorker[] = [];
|
private _workersWaitingForAgentConnected: GridWorker[] = [];
|
||||||
private _retireTimeout: NodeJS.Timeout | undefined;
|
private _retireTimeout = 30000;
|
||||||
|
private _retireTimeoutId: NodeJS.Timeout | undefined;
|
||||||
private _log: debug.Debugger;
|
private _log: debug.Debugger;
|
||||||
private _agentCreationTimeout: NodeJS.Timeout;
|
private _agentCreationTimeoutId: NodeJS.Timeout;
|
||||||
|
|
||||||
constructor(capacity = Infinity, creationTimeout = 5 * 60000) {
|
constructor(capacity = Infinity, creationTimeout = 5 * 60000, retireTimeout = 30000) {
|
||||||
super();
|
super();
|
||||||
this._capacity = capacity;
|
this._capacity = capacity;
|
||||||
this._log = debug(`[agent ${this.agentId}]`);
|
this._log = debug(`[agent ${this.agentId}]`);
|
||||||
this.setStatus('created');
|
this.setStatus('created');
|
||||||
this._agentCreationTimeout = setTimeout(() => {
|
this._retireTimeout = retireTimeout;
|
||||||
|
this._agentCreationTimeoutId = setTimeout(() => {
|
||||||
this.closeAgent(WSErrors.AGENT_CREATION_TIMED_OUT);
|
this.closeAgent(WSErrors.AGENT_CREATION_TIMED_OUT);
|
||||||
}, creationTimeout);
|
}, creationTimeout);
|
||||||
}
|
}
|
||||||
|
|
@ -126,7 +128,7 @@ class GridAgent extends EventEmitter {
|
||||||
}
|
}
|
||||||
|
|
||||||
agentConnected(ws: WebSocket) {
|
agentConnected(ws: WebSocket) {
|
||||||
clearTimeout(this._agentCreationTimeout);
|
clearTimeout(this._agentCreationTimeoutId);
|
||||||
this.setStatus('connected');
|
this.setStatus('connected');
|
||||||
this._ws = ws;
|
this._ws = ws;
|
||||||
for (const worker of this._workersWaitingForAgentConnected) {
|
for (const worker of this._workersWaitingForAgentConnected) {
|
||||||
|
|
@ -141,8 +143,8 @@ class GridAgent extends EventEmitter {
|
||||||
}
|
}
|
||||||
|
|
||||||
async createWorker(clientSocket: WebSocket) {
|
async createWorker(clientSocket: WebSocket) {
|
||||||
if (this._retireTimeout)
|
if (this._retireTimeoutId)
|
||||||
clearTimeout(this._retireTimeout);
|
clearTimeout(this._retireTimeoutId);
|
||||||
if (this._ws)
|
if (this._ws)
|
||||||
this.setStatus('connected');
|
this.setStatus('connected');
|
||||||
const worker = new GridWorker(clientSocket);
|
const worker = new GridWorker(clientSocket);
|
||||||
|
|
@ -152,9 +154,10 @@ class GridAgent extends EventEmitter {
|
||||||
this._workers.delete(worker.workerId);
|
this._workers.delete(worker.workerId);
|
||||||
if (!this._workers.size) {
|
if (!this._workers.size) {
|
||||||
this.setStatus('retiring');
|
this.setStatus('retiring');
|
||||||
if (this._retireTimeout)
|
if (this._retireTimeoutId)
|
||||||
clearTimeout(this._retireTimeout);
|
clearTimeout(this._retireTimeoutId);
|
||||||
this._retireTimeout = setTimeout(() => this.closeAgent(WSErrors.AGENT_RETIRED), 30000);
|
if (this._retireTimeout && isFinite(this._retireTimeout))
|
||||||
|
this._retireTimeoutId = setTimeout(() => this.closeAgent(WSErrors.AGENT_RETIRED), this._retireTimeout);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
if (this._ws) {
|
if (this._ws) {
|
||||||
|
|
@ -182,7 +185,7 @@ class GridAgent extends EventEmitter {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class GridServer {
|
export class GridServer {
|
||||||
private _server: HttpServer;
|
private _server: HttpServer;
|
||||||
private _wsServer: WebSocketServer;
|
private _wsServer: WebSocketServer;
|
||||||
private _agents = new Map<string, GridAgent>();
|
private _agents = new Map<string, GridAgent>();
|
||||||
|
|
@ -239,7 +242,7 @@ class GridServer {
|
||||||
ws.close(WSErrors.CLIENT_PLAYWRIGHT_VERSION_MISMATCH.code, WSErrors.CLIENT_PLAYWRIGHT_VERSION_MISMATCH.reason);
|
ws.close(WSErrors.CLIENT_PLAYWRIGHT_VERSION_MISMATCH.code, WSErrors.CLIENT_PLAYWRIGHT_VERSION_MISMATCH.reason);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const agent = [...this._agents.values()].find(w => w.canCreateWorker()) || this._createAgent();
|
const agent = [...this._agents.values()].find(w => w.canCreateWorker()) || this._createAgent()?.agent;
|
||||||
if (!agent) {
|
if (!agent) {
|
||||||
ws.close(WSErrors.AGENT_CREATION_FAILED.code, WSErrors.AGENT_CREATION_FAILED.reason);
|
ws.close(WSErrors.AGENT_CREATION_FAILED.code, WSErrors.AGENT_CREATION_FAILED.reason);
|
||||||
return;
|
return;
|
||||||
|
|
@ -282,25 +285,32 @@ class GridServer {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private _createAgent(): GridAgent {
|
public async createAgent() {
|
||||||
const agent = new GridAgent(this._factory.capacity, this._factory.timeout);
|
const { initPromise } = this._createAgent();
|
||||||
|
await initPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _createAgent(): {agent: GridAgent, initPromise: Promise<{success: boolean, error: any}>} {
|
||||||
|
const agent = new GridAgent(this._factory.capacity, this._factory.launchTimeout, this._factory.retireTimeout);
|
||||||
this._agents.set(agent.agentId, agent);
|
this._agents.set(agent.agentId, agent);
|
||||||
agent.on('close', () => {
|
agent.on('close', () => {
|
||||||
this._agents.delete(agent.agentId);
|
this._agents.delete(agent.agentId);
|
||||||
});
|
});
|
||||||
Promise.resolve()
|
const initPromise = Promise.resolve()
|
||||||
.then(() => this._factory.launch({
|
.then(() => this._factory.launch({
|
||||||
agentId: agent.agentId,
|
agentId: agent.agentId,
|
||||||
gridURL: this._server.urlPrefix(),
|
gridURL: this._server.urlPrefix(),
|
||||||
playwrightVersion: getPlaywrightVersion(),
|
playwrightVersion: getPlaywrightVersion(),
|
||||||
})).then(() => {
|
})).then(() => {
|
||||||
this._log('created');
|
this._log('created');
|
||||||
}).catch(e => {
|
return { success: true, error: undefined };
|
||||||
|
}).catch(error => {
|
||||||
this._log('failed to launch agent ' + agent.agentId);
|
this._log('failed to launch agent ' + agent.agentId);
|
||||||
console.error(e);
|
console.error(error);
|
||||||
agent.closeAgent(WSErrors.AGENT_CREATION_FAILED);
|
agent.closeAgent(WSErrors.AGENT_CREATION_FAILED);
|
||||||
|
return { success: false, error };
|
||||||
});
|
});
|
||||||
return agent;
|
return { agent, initPromise };
|
||||||
}
|
}
|
||||||
|
|
||||||
_securePath(suffix: string): string {
|
_securePath(suffix: string): string {
|
||||||
|
|
@ -338,7 +348,7 @@ class GridServer {
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async start(port: number) {
|
async start(port?: number) {
|
||||||
await this._server.start(port);
|
await this._server.start(port);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -357,21 +367,3 @@ class GridServer {
|
||||||
function mangle(sessionId: string) {
|
function mangle(sessionId: string) {
|
||||||
return sessionId.replace(/\w{28}/, 'x'.repeat(28));
|
return sessionId.replace(/\w{28}/, 'x'.repeat(28));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function launchGridServer(factoryPathOrPackageName: string, port: number, authToken: string|undefined): Promise<void> {
|
|
||||||
if (!factoryPathOrPackageName)
|
|
||||||
factoryPathOrPackageName = './simpleGridFactory';
|
|
||||||
let factory;
|
|
||||||
try {
|
|
||||||
factory = require(path.resolve(factoryPathOrPackageName));
|
|
||||||
} catch (e) {
|
|
||||||
factory = require(factoryPathOrPackageName);
|
|
||||||
}
|
|
||||||
if (!factory || !factory.launch || typeof factory.launch !== 'function')
|
|
||||||
throw new Error('factory does not export `launch` method');
|
|
||||||
factory.name = factory.name || factoryPathOrPackageName;
|
|
||||||
const gridServer = new GridServer(factory as GridFactory, authToken);
|
|
||||||
await gridServer.start(port);
|
|
||||||
/* eslint-disable no-console */
|
|
||||||
console.log('Grid server is running at ' + gridServer.urlPrefix());
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -15,22 +15,26 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import child_process from 'child_process';
|
import child_process from 'child_process';
|
||||||
import { GridAgentLaunchOptions } from './gridServer';
|
import { GridAgentLaunchOptions, GridFactory } from './gridServer';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
export const name = 'Agents co-located with grid';
|
const simpleFactory: GridFactory = {
|
||||||
export const capacity = Infinity;
|
name: 'Agents co-located with grid',
|
||||||
export const timeout = 10000;
|
capacity: Infinity,
|
||||||
export function launch({ agentId, gridURL }: GridAgentLaunchOptions) {
|
launchTimeout: 10000,
|
||||||
|
retireTimeout: 10000,
|
||||||
|
launch: async (options: GridAgentLaunchOptions) => {
|
||||||
child_process.spawn(process.argv[0], [
|
child_process.spawn(process.argv[0], [
|
||||||
path.join(__dirname, '..', 'cli', 'cli.js'),
|
path.join(__dirname, '..', 'cli', 'cli.js'),
|
||||||
'experimental-grid-agent',
|
'experimental-grid-agent',
|
||||||
'--grid-url', gridURL,
|
'--grid-url', options.gridURL,
|
||||||
'--agent-id', agentId,
|
'--agent-id', options.agentId,
|
||||||
], {
|
], {
|
||||||
cwd: __dirname,
|
cwd: __dirname,
|
||||||
shell: true,
|
shell: true,
|
||||||
stdio: 'inherit',
|
stdio: 'inherit',
|
||||||
});
|
});
|
||||||
}
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default simpleFactory;
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,9 @@ import { stopProfiling, startProfiling } from './profiler';
|
||||||
import { FilePatternFilter } from './util';
|
import { FilePatternFilter } from './util';
|
||||||
import { Loader } from './loader';
|
import { Loader } from './loader';
|
||||||
import { showHTMLReport } from './reporters/html';
|
import { showHTMLReport } from './reporters/html';
|
||||||
|
import { GridServer } from 'playwright-core/src/grid/gridServer';
|
||||||
|
import dockerFactory from 'playwright-core/src/grid/dockerGridFactory';
|
||||||
|
import { createGuid } from 'playwright-core/src/utils/utils';
|
||||||
|
|
||||||
const defaultTimeout = 30000;
|
const defaultTimeout = 30000;
|
||||||
const defaultReporter: BuiltInReporter = process.env.CI ? 'dot' : 'list';
|
const defaultReporter: BuiltInReporter = process.env.CI ? 'dot' : 'list';
|
||||||
|
|
@ -181,6 +184,8 @@ async function runTests(args: string[], opts: { [key: string]: any }) {
|
||||||
});
|
});
|
||||||
|
|
||||||
const runner = new Runner(loader);
|
const runner = new Runner(loader);
|
||||||
|
if (process.env.PLAYWRIGHT_DOCKER)
|
||||||
|
runner.addInternalGlobalSetup(launchDockerContainer);
|
||||||
const result = await runner.run(!!opts.list, filePatternFilters, opts.project || undefined);
|
const result = await runner.run(!!opts.list, filePatternFilters, opts.project || undefined);
|
||||||
await stopProfiling(undefined);
|
await stopProfiling(undefined);
|
||||||
|
|
||||||
|
|
@ -225,3 +230,12 @@ function resolveReporter(id: string) {
|
||||||
return localPath;
|
return localPath;
|
||||||
return require.resolve(id, { paths: [ process.cwd() ] });
|
return require.resolve(id, { paths: [ process.cwd() ] });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function launchDockerContainer(): Promise<() => Promise<void>> {
|
||||||
|
const gridServer = new GridServer(dockerFactory, createGuid());
|
||||||
|
await gridServer.start();
|
||||||
|
// Start docker container in advance.
|
||||||
|
await gridServer.createAgent();
|
||||||
|
process.env.PW_GRID = gridServer.urlPrefix().substring(0, gridServer.urlPrefix().length - 1);
|
||||||
|
return async () => await gridServer.stop();
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -56,10 +56,13 @@ type RunResult = {
|
||||||
clashingTests: Map<string, TestCase[]>
|
clashingTests: Map<string, TestCase[]>
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type InternalGlobalSetupFunction = () => Promise<() => Promise<void>>;
|
||||||
|
|
||||||
export class Runner {
|
export class Runner {
|
||||||
private _loader: Loader;
|
private _loader: Loader;
|
||||||
private _reporter!: Reporter;
|
private _reporter!: Reporter;
|
||||||
private _didBegin = false;
|
private _didBegin = false;
|
||||||
|
private _internalGlobalSetups: Array<InternalGlobalSetupFunction> = [];
|
||||||
|
|
||||||
constructor(loader: Loader) {
|
constructor(loader: Loader) {
|
||||||
this._loader = loader;
|
this._loader = loader;
|
||||||
|
|
@ -96,6 +99,10 @@ export class Runner {
|
||||||
return new Multiplexer(reporters);
|
return new Multiplexer(reporters);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
addInternalGlobalSetup(internalGlobalSetup: InternalGlobalSetupFunction) {
|
||||||
|
this._internalGlobalSetups.push(internalGlobalSetup);
|
||||||
|
}
|
||||||
|
|
||||||
async run(list: boolean, filePatternFilters: FilePatternFilter[], projectNames?: string[]): Promise<RunResultStatus> {
|
async run(list: boolean, filePatternFilters: FilePatternFilter[], projectNames?: string[]): Promise<RunResultStatus> {
|
||||||
this._reporter = await this._createReporter(list);
|
this._reporter = await this._createReporter(list);
|
||||||
const config = this._loader.fullConfig();
|
const config = this._loader.fullConfig();
|
||||||
|
|
@ -187,6 +194,11 @@ export class Runner {
|
||||||
testFiles.forEach(file => allTestFiles.add(file));
|
testFiles.forEach(file => allTestFiles.add(file));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const internalGlobalTeardowns: (() => Promise<void>)[] = [];
|
||||||
|
if (!list) {
|
||||||
|
for (const internalGlobalSetup of this._internalGlobalSetups)
|
||||||
|
internalGlobalTeardowns.push(await internalGlobalSetup());
|
||||||
|
}
|
||||||
const webServer = (!list && config.webServer) ? await WebServer.create(config.webServer) : undefined;
|
const webServer = (!list && config.webServer) ? await WebServer.create(config.webServer) : undefined;
|
||||||
let globalSetupResult: any;
|
let globalSetupResult: any;
|
||||||
if (config.globalSetup && !list)
|
if (config.globalSetup && !list)
|
||||||
|
|
@ -338,6 +350,8 @@ export class Runner {
|
||||||
if (config.globalTeardown && !list)
|
if (config.globalTeardown && !list)
|
||||||
await (await this._loader.loadGlobalHook(config.globalTeardown, 'globalTeardown'))(this._loader.fullConfig());
|
await (await this._loader.loadGlobalHook(config.globalTeardown, 'globalTeardown'))(this._loader.fullConfig());
|
||||||
await webServer?.kill();
|
await webServer?.kill();
|
||||||
|
for (const internalGlobalTeardown of internalGlobalTeardowns)
|
||||||
|
await internalGlobalTeardown();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
FROM ubuntu:bionic
|
FROM ubuntu:bionic
|
||||||
|
|
||||||
|
ARG DEBIAN_FRONTEND=noninteractive
|
||||||
|
ARG TZ=America/Los_Angeles
|
||||||
|
|
||||||
# === INSTALL Node.js ===
|
# === INSTALL Node.js ===
|
||||||
|
|
||||||
# Install node14
|
# Install node14
|
||||||
|
|
@ -24,23 +27,43 @@ RUN apt-get update && apt-get install -y python3.8 python3-pip && \
|
||||||
update-alternatives --install /usr/bin/python python /usr/bin/python3 1 && \
|
update-alternatives --install /usr/bin/python python /usr/bin/python3 1 && \
|
||||||
update-alternatives --install /usr/bin/python3 python3 /usr/bin/python3.8 1
|
update-alternatives --install /usr/bin/python3 python3 /usr/bin/python3.8 1
|
||||||
|
|
||||||
|
# Install VNC & noVNC
|
||||||
|
|
||||||
|
ARG NOVNC_REF="1.2.0"
|
||||||
|
ARG WEBSOCKIFY_REF="0.10.0"
|
||||||
|
ENV DISPLAY_NUM=99
|
||||||
|
ENV DISPLAY=":${DISPLAY_NUM}"
|
||||||
|
|
||||||
|
RUN mkdir -p /opt/bin && chmod +x /dev/shm \
|
||||||
|
&& apt-get update && apt-get install -y unzip fluxbox x11vnc \
|
||||||
|
&& curl -L -o noVNC.zip "https://github.com/novnc/noVNC/archive/v${NOVNC_REF}.zip" \
|
||||||
|
&& unzip -x noVNC.zip \
|
||||||
|
&& mv noVNC-${NOVNC_REF} /opt/bin/noVNC \
|
||||||
|
&& cp /opt/bin/noVNC/vnc.html /opt/bin/noVNC/index.html \
|
||||||
|
&& rm noVNC.zip \
|
||||||
|
&& curl -L -o websockify.zip "https://github.com/novnc/websockify/archive/v${WEBSOCKIFY_REF}.zip" \
|
||||||
|
&& unzip -x websockify.zip \
|
||||||
|
&& rm websockify.zip \
|
||||||
|
&& mv websockify-${WEBSOCKIFY_REF} /opt/bin/noVNC/utils/websockify
|
||||||
|
|
||||||
# === BAKE BROWSERS INTO IMAGE ===
|
# === BAKE BROWSERS INTO IMAGE ===
|
||||||
|
|
||||||
ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
|
ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
|
||||||
ENV DEBIAN_FRONTEND=noninteractive
|
|
||||||
|
|
||||||
# 1. Add tip-of-tree Playwright package to install its browsers.
|
# 1. Add tip-of-tree Playwright package to install its browsers.
|
||||||
# The package should be built beforehand from tip-of-tree Playwright.
|
# The package should be built beforehand from tip-of-tree Playwright.
|
||||||
COPY ./playwright-core.tar.gz /tmp/playwright-core.tar.gz
|
COPY ./playwright-core.tar.gz /tmp/playwright-core.tar.gz
|
||||||
|
|
||||||
# 2. Install playwright and then delete the installation.
|
# 2. Install playwright agent.
|
||||||
# Browsers will remain downloaded in `/ms-playwright`.
|
# Browsers will be downloaded in `/ms-playwright`.
|
||||||
# Note: make sure to set 777 to the registry so that any user can access
|
# Note: make sure to set 777 to the registry so that any user can access
|
||||||
# registry.
|
# registry.
|
||||||
RUN mkdir /ms-playwright && \
|
RUN mkdir /ms-playwright && \
|
||||||
mkdir /tmp/pw && cd /tmp/pw && npm init -y && \
|
mkdir /ms-playwright-agent && \
|
||||||
|
cd /ms-playwright-agent && npm init -y && \
|
||||||
npm i /tmp/playwright-core.tar.gz && \
|
npm i /tmp/playwright-core.tar.gz && \
|
||||||
npx playwright install && \
|
npx playwright install --with-deps && \
|
||||||
DEBIAN_FRONTEND=noninteractive npx playwright install-deps && \
|
rm /tmp/playwright-core.tar.gz && \
|
||||||
rm -rf /tmp/pw && rm /tmp/playwright-core.tar.gz && \
|
|
||||||
chmod -R 777 /ms-playwright
|
chmod -R 777 /ms-playwright
|
||||||
|
|
||||||
|
COPY start_agent.sh /ms-playwright-agent/start_agent.sh
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
FROM ubuntu:focal
|
FROM ubuntu:focal
|
||||||
|
|
||||||
|
ARG DEBIAN_FRONTEND=noninteractive
|
||||||
|
ARG TZ=America/Los_Angeles
|
||||||
|
|
||||||
# === INSTALL Node.js ===
|
# === INSTALL Node.js ===
|
||||||
|
|
||||||
# Install node14
|
# Install node14
|
||||||
|
|
@ -24,6 +27,25 @@ RUN apt-get update && apt-get install -y python3.8 python3-pip && \
|
||||||
update-alternatives --install /usr/bin/python python /usr/bin/python3 1 && \
|
update-alternatives --install /usr/bin/python python /usr/bin/python3 1 && \
|
||||||
update-alternatives --install /usr/bin/python3 python3 /usr/bin/python3.8 1
|
update-alternatives --install /usr/bin/python3 python3 /usr/bin/python3.8 1
|
||||||
|
|
||||||
|
# Install VNC & noVNC
|
||||||
|
|
||||||
|
ARG NOVNC_REF="1.2.0"
|
||||||
|
ARG WEBSOCKIFY_REF="0.10.0"
|
||||||
|
ENV DISPLAY_NUM=99
|
||||||
|
ENV DISPLAY=":${DISPLAY_NUM}"
|
||||||
|
|
||||||
|
RUN mkdir -p /opt/bin && chmod +x /dev/shm \
|
||||||
|
&& apt-get update && apt-get install -y unzip fluxbox x11vnc \
|
||||||
|
&& curl -L -o noVNC.zip "https://github.com/novnc/noVNC/archive/v${NOVNC_REF}.zip" \
|
||||||
|
&& unzip -x noVNC.zip \
|
||||||
|
&& mv noVNC-${NOVNC_REF} /opt/bin/noVNC \
|
||||||
|
&& cp /opt/bin/noVNC/vnc.html /opt/bin/noVNC/index.html \
|
||||||
|
&& rm noVNC.zip \
|
||||||
|
&& curl -L -o websockify.zip "https://github.com/novnc/websockify/archive/v${WEBSOCKIFY_REF}.zip" \
|
||||||
|
&& unzip -x websockify.zip \
|
||||||
|
&& rm websockify.zip \
|
||||||
|
&& mv websockify-${WEBSOCKIFY_REF} /opt/bin/noVNC/utils/websockify
|
||||||
|
|
||||||
# === BAKE BROWSERS INTO IMAGE ===
|
# === BAKE BROWSERS INTO IMAGE ===
|
||||||
|
|
||||||
ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
|
ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
|
||||||
|
|
@ -32,14 +54,16 @@ ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
|
||||||
# The package should be built beforehand from tip-of-tree Playwright.
|
# The package should be built beforehand from tip-of-tree Playwright.
|
||||||
COPY ./playwright-core.tar.gz /tmp/playwright-core.tar.gz
|
COPY ./playwright-core.tar.gz /tmp/playwright-core.tar.gz
|
||||||
|
|
||||||
# 2. Install playwright and then delete the installation.
|
# 2. Install playwright agent.
|
||||||
# Browsers will remain downloaded in `/ms-playwright`.
|
# Browsers will be downloaded in `/ms-playwright`.
|
||||||
# Note: make sure to set 777 to the registry so that any user can access
|
# Note: make sure to set 777 to the registry so that any user can access
|
||||||
# registry.
|
# registry.
|
||||||
RUN mkdir /ms-playwright && \
|
RUN mkdir /ms-playwright && \
|
||||||
mkdir /tmp/pw && cd /tmp/pw && npm init -y && \
|
mkdir /ms-playwright-agent && \
|
||||||
|
cd /ms-playwright-agent && npm init -y && \
|
||||||
npm i /tmp/playwright-core.tar.gz && \
|
npm i /tmp/playwright-core.tar.gz && \
|
||||||
npx playwright install && \
|
npx playwright install --with-deps && \
|
||||||
DEBIAN_FRONTEND=noninteractive npx playwright install-deps && \
|
rm /tmp/playwright-core.tar.gz && \
|
||||||
rm -rf /tmp/pw && rm /tmp/playwright-core.tar.gz && \
|
|
||||||
chmod -R 777 /ms-playwright
|
chmod -R 777 /ms-playwright
|
||||||
|
|
||||||
|
COPY start_agent.sh /ms-playwright-agent/start_agent.sh
|
||||||
|
|
|
||||||
28
utils/docker/start_agent.sh
Normal file
28
utils/docker/start_agent.sh
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
set +x
|
||||||
|
|
||||||
|
SCREEN_WIDTH=1360
|
||||||
|
SCREEN_HEIGHT=1020
|
||||||
|
SCREEN_DEPTH=24
|
||||||
|
SCREEN_DPI=96
|
||||||
|
GEOMETRY="${SCREEN_WIDTH}""x""${SCREEN_HEIGHT}""x""${SCREEN_DEPTH}"
|
||||||
|
|
||||||
|
nohup /usr/bin/xvfb-run --server-num=${DISPLAY_NUM} \
|
||||||
|
--listen-tcp \
|
||||||
|
--server-args="-screen 0 ${GEOMETRY} -fbdir /var/tmp -dpi ${SCREEN_DPI} -listen tcp -noreset -ac +extension RANDR" \
|
||||||
|
/usr/bin/fluxbox -display ${DISPLAY} >/dev/null 2>&1 &
|
||||||
|
|
||||||
|
for i in $(seq 1 50)
|
||||||
|
do
|
||||||
|
if xdpyinfo -display ${DISPLAY} >/dev/null 2>&1; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
echo "Waiting for Xvfb..."
|
||||||
|
sleep 0.2
|
||||||
|
done
|
||||||
|
|
||||||
|
nohup x11vnc -forever -shared -rfbport 5900 -rfbportv6 5900 -display ${DISPLAY} >/dev/null 2>&1 &
|
||||||
|
nohup /opt/bin/noVNC/utils/launch.sh --listen 7900 --vnc localhost:5900 >/dev/null 2>&1 &
|
||||||
|
|
||||||
|
npx playwright experimental-grid-agent --agent-id "$1" --grid-url "$2"
|
||||||
Loading…
Reference in a new issue