test: remove service2 mode and experimental-grid (#20730)
This commit is contained in:
parent
96f0674e41
commit
bc74383480
|
|
@ -20,7 +20,6 @@
|
||||||
},
|
},
|
||||||
"./cli": "./cli.js",
|
"./cli": "./cli.js",
|
||||||
"./package.json": "./package.json",
|
"./package.json": "./package.json",
|
||||||
"./lib/grid/gridServer": "./lib/grid/gridServer.js",
|
|
||||||
"./lib/outofprocess": "./lib/outofprocess.js",
|
"./lib/outofprocess": "./lib/outofprocess.js",
|
||||||
"./lib/image_tools/stats": "./lib/image_tools/stats.js",
|
"./lib/image_tools/stats": "./lib/image_tools/stats.js",
|
||||||
"./lib/image_tools/compare": "./lib/image_tools/compare.js",
|
"./lib/image_tools/compare": "./lib/image_tools/compare.js",
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@
|
||||||
../common
|
../common
|
||||||
../debug/injected
|
../debug/injected
|
||||||
../generated/
|
../generated/
|
||||||
../grid
|
|
||||||
../server/injected/
|
../server/injected/
|
||||||
../server/trace
|
../server/trace
|
||||||
../utils
|
../utils
|
||||||
|
|
|
||||||
|
|
@ -33,9 +33,6 @@ import type { BrowserType } from '../client/browserType';
|
||||||
import type { BrowserContextOptions, LaunchOptions } from '../client/types';
|
import type { BrowserContextOptions, LaunchOptions } from '../client/types';
|
||||||
import { spawn } from 'child_process';
|
import { spawn } from 'child_process';
|
||||||
import { wrapInASCIIBox, isLikelyNpxGlobal, assert } from '../utils';
|
import { wrapInASCIIBox, isLikelyNpxGlobal, assert } from '../utils';
|
||||||
import { launchGridAgent } from '../grid/gridAgent';
|
|
||||||
import type { GridFactory } from '../grid/gridServer';
|
|
||||||
import { GridServer } from '../grid/gridServer';
|
|
||||||
import type { Executable } from '../server';
|
import type { Executable } from '../server';
|
||||||
import { registry, writeDockerVersion } from '../server';
|
import { registry, writeDockerVersion } from '../server';
|
||||||
import { addContainerCLI } from '../containers/';
|
import { addContainerCLI } from '../containers/';
|
||||||
|
|
@ -240,25 +237,6 @@ Examples:
|
||||||
|
|
||||||
$ pdf https://example.com example.pdf`);
|
$ pdf https://example.com example.pdf`);
|
||||||
|
|
||||||
program
|
|
||||||
.command('experimental-grid-server', { hidden: true })
|
|
||||||
.option('--port <port>', 'grid port; defaults to 3333')
|
|
||||||
.option('--address <address>', 'address of the server')
|
|
||||||
.option('--agent-factory <factory>', 'path to grid agent factory or npm package')
|
|
||||||
.option('--auth-token <authToken>', 'optional authentication token')
|
|
||||||
.action(function(options) {
|
|
||||||
launchGridServer(options.agentFactory, options.port || 3333, options.address, options.authToken);
|
|
||||||
});
|
|
||||||
|
|
||||||
program
|
|
||||||
.command('experimental-grid-agent', { hidden: true })
|
|
||||||
.requiredOption('--agent-id <agentId>', 'agent ID')
|
|
||||||
.requiredOption('--grid-url <gridURL>', 'grid URL')
|
|
||||||
.option('--run-id <github run_id>', 'Workflow run_id')
|
|
||||||
.action(function(options) {
|
|
||||||
launchGridAgent(options.agentId, options.gridUrl, options.runId);
|
|
||||||
});
|
|
||||||
|
|
||||||
program
|
program
|
||||||
.command('run-driver', { hidden: true })
|
.command('run-driver', { hidden: true })
|
||||||
.action(function(options) {
|
.action(function(options) {
|
||||||
|
|
@ -718,25 +696,6 @@ function commandWithOpenOptions(command: string, description: string, options: a
|
||||||
.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, address: string | undefined, 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, address);
|
|
||||||
await gridServer.start(port);
|
|
||||||
console.log('Grid server is running at ' + gridServer.gridURL());
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildBasePlaywrightCLICommand(cliTargetLang: string | undefined): string {
|
function buildBasePlaywrightCLICommand(cliTargetLang: string | undefined): string {
|
||||||
switch (cliTargetLang) {
|
switch (cliTargetLang) {
|
||||||
case 'python':
|
case 'python':
|
||||||
|
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
[*]
|
|
||||||
../client/
|
|
||||||
../common/
|
|
||||||
../dispatchers/
|
|
||||||
../remote/
|
|
||||||
../server/
|
|
||||||
../utils/
|
|
||||||
../utilsBundle.ts
|
|
||||||
|
|
@ -1,75 +0,0 @@
|
||||||
/**
|
|
||||||
* 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 type { GridAgentLaunchOptions, GridFactory } from './gridServer';
|
|
||||||
import https from 'https';
|
|
||||||
import { debug } from '../utilsBundle';
|
|
||||||
|
|
||||||
const repoName = process.env.GITHUB_AGENT_REPO;
|
|
||||||
if (!repoName)
|
|
||||||
throw new Error('GITHUB_AGENT_REPO is not specified.');
|
|
||||||
|
|
||||||
const repoAccessToken = process.env.GITHUB_AGENT_REPO_ACCESS_TOKEN;
|
|
||||||
if (!repoAccessToken)
|
|
||||||
throw new Error('GITHUB_AGENT_REPO_ACCESS_TOKEN is not specified.');
|
|
||||||
|
|
||||||
const log = debug(`pw:grid:server`);
|
|
||||||
|
|
||||||
const githubFactory: GridFactory = {
|
|
||||||
name: 'Agents hosted on Github',
|
|
||||||
// Standard VM is 3-core on mac and 2-core on win and lin
|
|
||||||
capacity: 4,
|
|
||||||
launchTimeout: 10 * 60_000,
|
|
||||||
retireTimeout: 1 * 60 * 60_000,
|
|
||||||
statusUrl: (runId: string) => {
|
|
||||||
return `https://github.com/${repoName}/actions/runs/${runId}`;
|
|
||||||
},
|
|
||||||
launch: async (options: GridAgentLaunchOptions) => {
|
|
||||||
await createWorkflow(options);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
async function createWorkflow(inputs: GridAgentLaunchOptions): Promise<boolean> {
|
|
||||||
if (!['windows', 'linux', 'macos'].includes(inputs.os)) {
|
|
||||||
log(`unsupported OS: ${inputs.os}`);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return new Promise(fulfill => {
|
|
||||||
log(`triggering workflow ${JSON.stringify(inputs)}`);
|
|
||||||
const req = https.request(`https://api.github.com/repos/${repoName}/actions/workflows/agent.yml/dispatches`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'User-Agent': 'request',
|
|
||||||
'Accept': 'application/vnd.github.v3+json',
|
|
||||||
'Authorization': `token ${repoAccessToken}`,
|
|
||||||
}
|
|
||||||
}, response => {
|
|
||||||
log(`workflow ${inputs.agentId} response: ${response.statusCode} ${response.statusMessage}`);
|
|
||||||
const success = !!response.statusCode && 200 <= response.statusCode && response.statusCode < 300;
|
|
||||||
fulfill(success);
|
|
||||||
});
|
|
||||||
req.on('error', e => {
|
|
||||||
log(`failed to create workflow ${inputs.agentId}`);
|
|
||||||
fulfill(false);
|
|
||||||
});
|
|
||||||
req.end(JSON.stringify({
|
|
||||||
'ref': 'refs/heads/main',
|
|
||||||
inputs
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export default githubFactory;
|
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
/**
|
|
||||||
* 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 { debug } from '../utilsBundle';
|
|
||||||
import { ws as WebSocket } from '../utilsBundle';
|
|
||||||
import { fork } from 'child_process';
|
|
||||||
import { getPlaywrightVersion } from '../utils/userAgent';
|
|
||||||
|
|
||||||
export function launchGridAgent(agentId: string, gridURL: string, runId: string | undefined) {
|
|
||||||
const log = debug(`pw:grid:agent:${agentId}`);
|
|
||||||
log('created');
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
params.set('pwVersion', getPlaywrightVersion(true /* majorMinorOnly */));
|
|
||||||
params.set('agentId', agentId);
|
|
||||||
if (runId)
|
|
||||||
params.set('runId', runId);
|
|
||||||
const ws = new WebSocket(gridURL.replace('http://', 'ws://') + `/registerAgent?` + params.toString());
|
|
||||||
ws.on('message', (message: string) => {
|
|
||||||
log('worker requested ' + message);
|
|
||||||
const { workerId, browserName } = JSON.parse(message);
|
|
||||||
if (!workerId) {
|
|
||||||
log('workerId not specified');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!browserName) {
|
|
||||||
log('browserName not specified');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
fork(require.resolve('./gridBrowserWorker.js'), [gridURL, agentId, workerId, browserName], { detached: true });
|
|
||||||
});
|
|
||||||
ws.on('close', () => process.exit(0));
|
|
||||||
}
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
/**
|
|
||||||
* 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 { debug } from '../utilsBundle';
|
|
||||||
import { ws as WebSocket } from '../utilsBundle';
|
|
||||||
import { PlaywrightConnection } from '../remote/playwrightConnection';
|
|
||||||
import { gracefullyCloseAll } from '../utils/processLauncher';
|
|
||||||
|
|
||||||
function launchGridBrowserWorker(gridURL: string, agentId: string, workerId: string, browserName: string) {
|
|
||||||
const log = debug(`pw:grid:worker:${workerId}`);
|
|
||||||
log('created');
|
|
||||||
const ws = new WebSocket(gridURL.replace('http://', 'ws://') + `/registerWorker?agentId=${agentId}&workerId=${workerId}`);
|
|
||||||
new PlaywrightConnection(Promise.resolve(), 'launch-browser', ws, { socksProxyPattern: '*', browserName, launchOptions: {} }, { }, log, async () => {
|
|
||||||
log('exiting process');
|
|
||||||
setTimeout(() => process.exit(0), 30000);
|
|
||||||
// Meanwhile, try to gracefully close all browsers.
|
|
||||||
await gracefullyCloseAll();
|
|
||||||
process.exit(0);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
launchGridBrowserWorker(process.argv[2], process.argv[3], process.argv[4], process.argv[5]);
|
|
||||||
|
|
@ -1,413 +0,0 @@
|
||||||
/**
|
|
||||||
* 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 { debug } from '../utilsBundle';
|
|
||||||
import { EventEmitter } from 'events';
|
|
||||||
import { URL } from 'url';
|
|
||||||
import type { WebSocketServer, WebSocket, WebSocketRawData } from '../utilsBundle';
|
|
||||||
import { HttpServer } from '../utils/httpServer';
|
|
||||||
import { assert, createGuid } from '../utils';
|
|
||||||
import { getPlaywrightVersion } from '../utils/userAgent';
|
|
||||||
|
|
||||||
const defaultOS = 'linux';
|
|
||||||
|
|
||||||
export type GridAgentLaunchOptions = {
|
|
||||||
agentId: string,
|
|
||||||
gridURL: string,
|
|
||||||
playwrightVersion: string,
|
|
||||||
os: string,
|
|
||||||
};
|
|
||||||
|
|
||||||
export type GridFactory = {
|
|
||||||
name?: string,
|
|
||||||
capacity?: number,
|
|
||||||
launchTimeout?: number,
|
|
||||||
retireTimeout?: number,
|
|
||||||
statusUrl?: (runId: string) => string;
|
|
||||||
launch: (launchOptions: GridAgentLaunchOptions) => Promise<void>,
|
|
||||||
};
|
|
||||||
|
|
||||||
type ErrorCode = {
|
|
||||||
code: number,
|
|
||||||
reason: string,
|
|
||||||
};
|
|
||||||
|
|
||||||
const WSErrors = {
|
|
||||||
NO_ERROR: { code: 1000, reason: '' },
|
|
||||||
AUTH_FAILED: { code: 1008, reason: 'Grid authentication 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_CONNECTED: { code: 1013, reason: 'Grid worker registration failed - agent has unsupported status' },
|
|
||||||
AGENT_CREATION_TIMED_OUT: { code: 1013, reason: 'Grid agent creation timed out' },
|
|
||||||
AGENT_RETIRED: { code: 1000, reason: 'Grid agent was retired' },
|
|
||||||
CLIENT_SOCKET_ERROR: { code: 1011, reason: 'Grid client socket error' },
|
|
||||||
WORKER_SOCKET_ERROR: { code: 1011, reason: 'Grid worker socket error' },
|
|
||||||
CLIENT_PLAYWRIGHT_VERSION_MISMATCH: { code: 1013, reason: 'Grid Playwright and grid client versions are different' },
|
|
||||||
AGENT_PLAYWRIGHT_VERSION_MISMATCH: { code: 1013, reason: 'Grid Playwright and grid agent versions are different' },
|
|
||||||
CLIENT_UNSUPPORTED_OS: { code: 1013, reason: 'Unsupported OS' },
|
|
||||||
GRID_SHUTDOWN: { code: 1000, reason: 'Grid was shutdown' },
|
|
||||||
AGENT_MANUALLY_STOPPED: { code: 1000, reason: 'Grid agent was manually stopped' },
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
type GridWorkerParams = {
|
|
||||||
browserName?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
class GridWorker extends EventEmitter {
|
|
||||||
readonly workerId = createGuid();
|
|
||||||
readonly params: GridWorkerParams;
|
|
||||||
private _workerSocket: WebSocket | undefined;
|
|
||||||
private _clientSocket: WebSocket;
|
|
||||||
private _log: debug.Debugger;
|
|
||||||
private _bufferedMessages: WebSocketRawData[] = [];
|
|
||||||
|
|
||||||
constructor(clientSocket: WebSocket, params: GridWorkerParams) {
|
|
||||||
super();
|
|
||||||
this._log = debug(`pw:grid:worker:${this.workerId}`);
|
|
||||||
this._clientSocket = clientSocket;
|
|
||||||
this.params = params;
|
|
||||||
clientSocket.on('close', (code: number, reason: string) => this.closeWorker(WSErrors.NO_ERROR));
|
|
||||||
clientSocket.on('error', (error: Error) => this.closeWorker(WSErrors.CLIENT_SOCKET_ERROR));
|
|
||||||
// clientSocket.pause() would be preferrable but according to the docs " Some events can still be
|
|
||||||
// emitted after it is called, until all buffered data is consumed."
|
|
||||||
this._clientSocket.on('message', data => {
|
|
||||||
if (this._workerSocket)
|
|
||||||
this._workerSocket.send(data);
|
|
||||||
else
|
|
||||||
this._bufferedMessages.push(data);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
workerConnected(workerSocket: WebSocket) {
|
|
||||||
this._log('connected');
|
|
||||||
this._workerSocket = workerSocket;
|
|
||||||
workerSocket.on('close', (code: number, reason: string) => this.closeWorker(WSErrors.NO_ERROR));
|
|
||||||
workerSocket.on('error', (error: Error) => this.closeWorker(WSErrors.WORKER_SOCKET_ERROR));
|
|
||||||
workerSocket.on('message', data => this._clientSocket!.send(data));
|
|
||||||
for (const data of this._bufferedMessages)
|
|
||||||
workerSocket.send(data);
|
|
||||||
this._bufferedMessages = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
closeWorker(errorCode: ErrorCode) {
|
|
||||||
this._log(`close ${errorCode.reason}`);
|
|
||||||
this._workerSocket?.close(errorCode.code, errorCode.reason);
|
|
||||||
this._clientSocket.close(errorCode.code, errorCode.reason);
|
|
||||||
this.emit('close');
|
|
||||||
}
|
|
||||||
|
|
||||||
debugInfo() {
|
|
||||||
return { worker: !!this._workerSocket, client: !!this._clientSocket };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type AgentStatus = 'none' | 'created' | 'connected' | 'idle';
|
|
||||||
|
|
||||||
class GridAgent extends EventEmitter {
|
|
||||||
private _capacity: number;
|
|
||||||
readonly agentId = createGuid();
|
|
||||||
readonly os: string;
|
|
||||||
private _ws: WebSocket | undefined;
|
|
||||||
runId: string | undefined;
|
|
||||||
readonly _workers = new Map<string, GridWorker>();
|
|
||||||
private _status: AgentStatus = 'none';
|
|
||||||
private _workersWaitingForAgentConnected: Set<GridWorker> = new Set();
|
|
||||||
private _retireTimeout = 30000;
|
|
||||||
private _retireTimeoutId: NodeJS.Timeout | undefined;
|
|
||||||
private _log: debug.Debugger;
|
|
||||||
private _agentCreationTimeoutId: NodeJS.Timeout;
|
|
||||||
|
|
||||||
constructor(os: string, capacity = Infinity, creationTimeout = 5 * 60000, retireTimeout = 30000) {
|
|
||||||
super();
|
|
||||||
this.os = os;
|
|
||||||
this._capacity = capacity;
|
|
||||||
this._log = debug(`pw:grid:agent:${this.agentId}`);
|
|
||||||
this.setStatus('created');
|
|
||||||
this._retireTimeout = retireTimeout;
|
|
||||||
this._agentCreationTimeoutId = setTimeout(() => {
|
|
||||||
this.closeAgent(WSErrors.AGENT_CREATION_TIMED_OUT);
|
|
||||||
}, creationTimeout);
|
|
||||||
}
|
|
||||||
|
|
||||||
public status(): AgentStatus {
|
|
||||||
return this._status;
|
|
||||||
}
|
|
||||||
|
|
||||||
setStatus(status: AgentStatus) {
|
|
||||||
this._log(`status ${this._status} => ${status}`);
|
|
||||||
this._status = status;
|
|
||||||
}
|
|
||||||
|
|
||||||
agentConnected(ws: WebSocket, runId?: string) {
|
|
||||||
clearTimeout(this._agentCreationTimeoutId);
|
|
||||||
this.setStatus('connected');
|
|
||||||
this._ws = ws;
|
|
||||||
this.runId = runId;
|
|
||||||
for (const worker of this._workersWaitingForAgentConnected)
|
|
||||||
this._sendStartWorkerMessage(worker);
|
|
||||||
this._workersWaitingForAgentConnected.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
canCreateWorker(os: string) {
|
|
||||||
return this.os === os && this._workers.size < this._capacity;
|
|
||||||
}
|
|
||||||
|
|
||||||
async createWorker(clientSocket: WebSocket, params: GridWorkerParams) {
|
|
||||||
if (this._retireTimeoutId)
|
|
||||||
clearTimeout(this._retireTimeoutId);
|
|
||||||
if (this._ws)
|
|
||||||
this.setStatus('connected');
|
|
||||||
const worker = new GridWorker(clientSocket, params);
|
|
||||||
this._log(`create worker: ${worker.workerId}`);
|
|
||||||
this._workers.set(worker.workerId, worker);
|
|
||||||
worker.on('close', () => {
|
|
||||||
this._workers.delete(worker.workerId);
|
|
||||||
this._workersWaitingForAgentConnected.delete(worker);
|
|
||||||
if (!this._workers.size) {
|
|
||||||
this.setStatus('idle');
|
|
||||||
if (this._retireTimeoutId)
|
|
||||||
clearTimeout(this._retireTimeoutId);
|
|
||||||
if (this._retireTimeout && isFinite(this._retireTimeout))
|
|
||||||
this._retireTimeoutId = setTimeout(() => this.closeAgent(WSErrors.AGENT_RETIRED), this._retireTimeout);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (this._ws)
|
|
||||||
this._sendStartWorkerMessage(worker);
|
|
||||||
else
|
|
||||||
this._workersWaitingForAgentConnected.add(worker);
|
|
||||||
}
|
|
||||||
|
|
||||||
workerConnected(workerId: string, ws: WebSocket) {
|
|
||||||
this._log(`worker connected: ${workerId}`);
|
|
||||||
const worker = this._workers.get(workerId)!;
|
|
||||||
worker.workerConnected(ws);
|
|
||||||
}
|
|
||||||
|
|
||||||
closeAgent(errorCode: ErrorCode) {
|
|
||||||
for (const worker of this._workersWaitingForAgentConnected)
|
|
||||||
worker.closeWorker(errorCode);
|
|
||||||
for (const worker of this._workers.values())
|
|
||||||
worker.closeWorker(errorCode);
|
|
||||||
this._log('close');
|
|
||||||
this._ws?.close(errorCode.code, errorCode.reason);
|
|
||||||
this.emit('close');
|
|
||||||
}
|
|
||||||
|
|
||||||
private _sendStartWorkerMessage(worker: GridWorker) {
|
|
||||||
const message = JSON.stringify({
|
|
||||||
...worker.params,
|
|
||||||
'workerId': worker.workerId,
|
|
||||||
});
|
|
||||||
this._log(`start worker message: ${message}`);
|
|
||||||
assert(this._ws);
|
|
||||||
this._ws.send(message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class GridServer {
|
|
||||||
private _server: HttpServer;
|
|
||||||
private _wsServer: WebSocketServer;
|
|
||||||
private _agents = new Map<string, GridAgent>();
|
|
||||||
private _log: debug.Debugger;
|
|
||||||
private _authToken: string;
|
|
||||||
private _factory: GridFactory;
|
|
||||||
private _pwVersion: string;
|
|
||||||
|
|
||||||
constructor(factory: GridFactory, authToken: string = '', address: string = '') {
|
|
||||||
this._log = debug(`pw:grid:server`);
|
|
||||||
this._log(`using factory ${factory.name}`);
|
|
||||||
this._authToken = authToken || '';
|
|
||||||
this._server = new HttpServer(address);
|
|
||||||
this._factory = factory;
|
|
||||||
this._pwVersion = getPlaywrightVersion(true /* majorMinorOnly */);
|
|
||||||
|
|
||||||
this._server.routePath(this._securePath('/'), (request, response) => {
|
|
||||||
response.statusCode = 200;
|
|
||||||
response.setHeader('Content-Type', 'text/html');
|
|
||||||
response.end(this._state());
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
this._server.routePath(this._securePath('/stopAll'), (request, response) => {
|
|
||||||
for (const agent of this._agents.values())
|
|
||||||
agent.closeAgent(WSErrors.AGENT_MANUALLY_STOPPED);
|
|
||||||
response.statusCode = 302;
|
|
||||||
response.setHeader('Location', this._securePath('/'));
|
|
||||||
response.end();
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
this._wsServer = this._server.createWebSocketServer();
|
|
||||||
|
|
||||||
this._wsServer.shouldHandle = request => {
|
|
||||||
this._log(request.url);
|
|
||||||
if (request.url!.startsWith(this._securePath('/claimWorker')) ||
|
|
||||||
request.url!.startsWith(this._securePath('/registerAgent')) ||
|
|
||||||
request.url!.startsWith(this._securePath('/registerWorker'))) {
|
|
||||||
// shouldHandle claims it accepts promise, except it doesn't.
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
this._log('rejecting websocket request');
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
this._wsServer.on('connection', async (ws, request) => {
|
|
||||||
if (request.url?.startsWith(this._securePath('/claimWorker'))) {
|
|
||||||
const params = new URL('http://localhost/' + request.url).searchParams;
|
|
||||||
const version = params.get('pwVersion');
|
|
||||||
if (version !== this._pwVersion && !process.env.PWTEST_UNSAFE_GRID_VERSION) {
|
|
||||||
this._log(`version mismatch: ${version} !== ${this._pwVersion}`);
|
|
||||||
ws.close(WSErrors.CLIENT_PLAYWRIGHT_VERSION_MISMATCH.code, WSErrors.CLIENT_PLAYWRIGHT_VERSION_MISMATCH.reason);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const os = params.get('os') || defaultOS;
|
|
||||||
const agent = [...this._agents.values()].find(w => w.canCreateWorker(os)) || this._createAgent(os)?.agent;
|
|
||||||
if (!agent) {
|
|
||||||
this._log(`failed to get agent`);
|
|
||||||
ws.close(WSErrors.AGENT_CREATION_FAILED.code, WSErrors.AGENT_CREATION_FAILED.reason);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
agent.createWorker(ws, {
|
|
||||||
browserName: request.headers['x-playwright-browser'] as string,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (request.url?.startsWith(this._securePath('/registerAgent'))) {
|
|
||||||
const params = new URL('http://localhost/' + request.url).searchParams;
|
|
||||||
if (params.get('pwVersion') !== this._pwVersion) {
|
|
||||||
ws.close(WSErrors.AGENT_PLAYWRIGHT_VERSION_MISMATCH.code, WSErrors.AGENT_PLAYWRIGHT_VERSION_MISMATCH.reason);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const agentId = params.get('agentId')!;
|
|
||||||
const agent = this._agents.get(agentId);
|
|
||||||
if (!agent) {
|
|
||||||
ws.close(WSErrors.AGENT_NOT_FOUND.code, WSErrors.AGENT_NOT_FOUND.reason);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const runId = params.get('runId') || undefined;
|
|
||||||
agent.agentConnected(ws, runId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (request.url?.startsWith(this._securePath('/registerWorker'))) {
|
|
||||||
const params = new URL('http://localhost/' + request.url).searchParams;
|
|
||||||
const agentId = params.get('agentId')!;
|
|
||||||
const workerId = params.get('workerId')!;
|
|
||||||
const agent = this._agents.get(agentId);
|
|
||||||
if (!agent)
|
|
||||||
ws.close(WSErrors.AGENT_NOT_FOUND.code, WSErrors.AGENT_NOT_FOUND.reason);
|
|
||||||
else if (agent.status() !== 'connected')
|
|
||||||
ws.close(WSErrors.AGENT_NOT_CONNECTED.code, WSErrors.AGENT_NOT_CONNECTED.reason);
|
|
||||||
else
|
|
||||||
agent.workerConnected(workerId, ws);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public async createAgent(): Promise<{ error: any }> {
|
|
||||||
const { initPromise } = this._createAgent(defaultOS);
|
|
||||||
return await initPromise;
|
|
||||||
}
|
|
||||||
|
|
||||||
private _createAgent(os: string): { agent: GridAgent, initPromise: Promise<{ error: any }> } {
|
|
||||||
const agent = new GridAgent(os, this._factory.capacity, this._factory.launchTimeout, this._factory.retireTimeout);
|
|
||||||
this._agents.set(agent.agentId, agent);
|
|
||||||
agent.on('close', () => {
|
|
||||||
this._agents.delete(agent.agentId);
|
|
||||||
});
|
|
||||||
const initPromise = Promise.resolve()
|
|
||||||
.then(() => this._factory.launch({
|
|
||||||
agentId: agent.agentId,
|
|
||||||
gridURL: this.gridURL(),
|
|
||||||
playwrightVersion: getPlaywrightVersion(),
|
|
||||||
os
|
|
||||||
})).then(() => {
|
|
||||||
this._log('created');
|
|
||||||
return { error: undefined };
|
|
||||||
}).catch(error => {
|
|
||||||
this._log('failed to launch agent ' + agent.agentId);
|
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.error(error);
|
|
||||||
agent.closeAgent(WSErrors.AGENT_CREATION_FAILED);
|
|
||||||
return { error };
|
|
||||||
});
|
|
||||||
return { agent, initPromise };
|
|
||||||
}
|
|
||||||
|
|
||||||
_securePath(suffix: string): string {
|
|
||||||
return this._authToken ? '/' + this._authToken + suffix : suffix;
|
|
||||||
}
|
|
||||||
|
|
||||||
private _state(): string {
|
|
||||||
const linkifyStatus = (agent: GridAgent) => {
|
|
||||||
if (agent.runId && this._factory.statusUrl)
|
|
||||||
return `<a href="${this._factory.statusUrl(agent.runId)}">${agent.status()}</a>`;
|
|
||||||
return agent.status();
|
|
||||||
};
|
|
||||||
return `
|
|
||||||
<section style="display: flex; flex-direction: row">
|
|
||||||
<div style="display: flex; flex-direction: column; align-items: end; margin-right: 1ex;">
|
|
||||||
<span>Grid Playwright Version:</span>
|
|
||||||
<span>Agent Factory:</span>
|
|
||||||
<span>Agents:</span>
|
|
||||||
</div>
|
|
||||||
<div style="display: flex; flex-direction: column">
|
|
||||||
<span>${this._pwVersion}</span>
|
|
||||||
<span>${this._factory.name}</span>
|
|
||||||
<span>${this._agents.size} <a href="./stopAll">(Stop All)</a></span>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<hr/>
|
|
||||||
<ul>
|
|
||||||
${[...this._agents].map(([agentId, agent]) => `
|
|
||||||
<li>
|
|
||||||
<div>Agent (${agent.os}) <code>${mangle(agentId)}</code>: ${linkifyStatus(agent)}</div>
|
|
||||||
<div>Workers: ${agent._workers.size}</div>
|
|
||||||
<ul>
|
|
||||||
${[...agent._workers].map(([workerId, worker]) => `
|
|
||||||
<li>worker <code>${mangle(workerId)}</code> - ${JSON.stringify(worker.debugInfo())}</li>
|
|
||||||
`).join('')}
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
`).join('')}
|
|
||||||
</ul>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async start(port?: number) {
|
|
||||||
await this._server.start({ port });
|
|
||||||
}
|
|
||||||
|
|
||||||
gridURL(): string {
|
|
||||||
return this._server.urlPrefix() + this._securePath('');
|
|
||||||
}
|
|
||||||
|
|
||||||
async stop() {
|
|
||||||
for (const agent of this._agents.values())
|
|
||||||
agent.closeAgent(WSErrors.GRID_SHUTDOWN);
|
|
||||||
assert(this._agents.size === 0);
|
|
||||||
await this._server.stop();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function mangle(sessionId: string) {
|
|
||||||
return sessionId.replace(/\w{28}/, 'x'.repeat(28));
|
|
||||||
}
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
/**
|
|
||||||
* 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 child_process from 'child_process';
|
|
||||||
import type { GridAgentLaunchOptions, GridFactory } from './gridServer';
|
|
||||||
import path from 'path';
|
|
||||||
|
|
||||||
const simpleFactory: GridFactory = {
|
|
||||||
name: 'Agents co-located with grid',
|
|
||||||
capacity: Infinity,
|
|
||||||
launchTimeout: 10000,
|
|
||||||
retireTimeout: 10000,
|
|
||||||
launch: async (options: GridAgentLaunchOptions) => {
|
|
||||||
child_process.spawn(process.argv[0], [
|
|
||||||
path.join(__dirname, '..', 'cli', 'cli.js'),
|
|
||||||
'experimental-grid-agent',
|
|
||||||
'--grid-url', options.gridURL,
|
|
||||||
'--agent-id', options.agentId,
|
|
||||||
], {
|
|
||||||
cwd: __dirname,
|
|
||||||
shell: true,
|
|
||||||
stdio: 'inherit',
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default simpleFactory;
|
|
||||||
|
|
@ -17,7 +17,7 @@
|
||||||
import { start } from '../../packages/playwright-core/lib/outofprocess';
|
import { start } from '../../packages/playwright-core/lib/outofprocess';
|
||||||
import type { Playwright } from '../../packages/playwright-core/lib/client/playwright';
|
import type { Playwright } from '../../packages/playwright-core/lib/client/playwright';
|
||||||
|
|
||||||
export type TestModeName = 'default' | 'driver' | 'service' | 'service2' | 'docker_remote';
|
export type TestModeName = 'default' | 'driver' | 'service' | 'docker_remote';
|
||||||
|
|
||||||
interface TestMode {
|
interface TestMode {
|
||||||
setup(): Promise<Playwright>;
|
setup(): Promise<Playwright>;
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,6 @@ export const testModeTest = test.extend<TestModeTestFixtures, TestModeWorkerOpti
|
||||||
docker_remote: new DefaultTestMode(),
|
docker_remote: new DefaultTestMode(),
|
||||||
service: new DefaultTestMode(),
|
service: new DefaultTestMode(),
|
||||||
driver: new DriverTestMode(),
|
driver: new DriverTestMode(),
|
||||||
service2: new DefaultTestMode(),
|
|
||||||
}[mode];
|
}[mode];
|
||||||
require('playwright-core/lib/utils').setUnderTest();
|
require('playwright-core/lib/utils').setUnderTest();
|
||||||
const playwright = await testMode.setup();
|
const playwright = await testMode.setup();
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ const getExecutablePath = (browserName: BrowserName) => {
|
||||||
return process.env.WKPATH;
|
return process.env.WKPATH;
|
||||||
};
|
};
|
||||||
|
|
||||||
const mode: TestModeName = (process.env.PWTEST_MODE ?? 'default') as ('default' | 'driver' | 'service' | 'service2' | 'docker_remote');
|
const mode: TestModeName = (process.env.PWTEST_MODE ?? 'default') as ('default' | 'driver' | 'service' | 'docker_remote');
|
||||||
const headed = process.argv.includes('--headed');
|
const headed = process.argv.includes('--headed');
|
||||||
const channel = process.env.PWTEST_CHANNEL as any;
|
const channel = process.env.PWTEST_CHANNEL as any;
|
||||||
const video = !!process.env.PWTEST_VIDEO;
|
const video = !!process.env.PWTEST_VIDEO;
|
||||||
|
|
@ -77,39 +77,6 @@ const config: Config<CoverageWorkerOptions & PlaywrightWorkerOptions & Playwrigh
|
||||||
} : undefined,
|
} : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (mode === 'service2') {
|
|
||||||
config.webServer = {
|
|
||||||
command: 'npx playwright experimental-grid-server --auth-token=mysecret --address=http://localhost:3333 --port=3333',
|
|
||||||
port: 3333,
|
|
||||||
reuseExistingServer: !process.env.CI,
|
|
||||||
env: {
|
|
||||||
PWTEST_UNSAFE_GRID_VERSION: '1',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
config.use.connectOptions = {
|
|
||||||
wsEndpoint: 'ws://localhost:3333/mysecret/claimWorker?os=linux',
|
|
||||||
};
|
|
||||||
config.projects = [{
|
|
||||||
name: 'Chromium page tests',
|
|
||||||
testMatch: /page\/.*spec.ts$/,
|
|
||||||
testIgnore: '**/*screenshot*',
|
|
||||||
use: {
|
|
||||||
browserName: 'chromium',
|
|
||||||
mode
|
|
||||||
},
|
|
||||||
metadata: {
|
|
||||||
platform: process.platform,
|
|
||||||
docker: !!process.env.INSIDE_DOCKER,
|
|
||||||
headful: !!headed,
|
|
||||||
browserName: 'chromium',
|
|
||||||
channel,
|
|
||||||
mode,
|
|
||||||
video: !!video,
|
|
||||||
trace: !!trace,
|
|
||||||
},
|
|
||||||
}];
|
|
||||||
}
|
|
||||||
|
|
||||||
const browserNames = ['chromium', 'webkit', 'firefox'] as BrowserName[];
|
const browserNames = ['chromium', 'webkit', 'firefox'] as BrowserName[];
|
||||||
for (const browserName of browserNames) {
|
for (const browserName of browserNames) {
|
||||||
const executablePath = getExecutablePath(browserName);
|
const executablePath = getExecutablePath(browserName);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue