feat: introduce docker integration for Playwright Test (#9599)

This commit is contained in:
Andrey Lushnikov 2021-10-19 16:10:24 -07:00 committed by GitHub
parent ba57be99a9
commit 983cfde4d4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 337 additions and 71 deletions

View file

@ -20,7 +20,9 @@
"require": "./index.js"
},
"./cli": "./cli.js",
"./src/grid/gridServer": "./lib/grid/gridServer.js",
"./src/grid/gridClient": "./lib/grid/gridClient.js",
"./src/grid/dockerGridFactory": "./lib/grid/dockerGridFactory.js",
"./src/utils/async": "./lib/utils/async.js",
"./src/utils/httpServer": "./lib/utils/httpServer.js",
"./src/utils/processLauncher": "./lib/utils/processLauncher.js",

View file

@ -34,7 +34,7 @@ import { spawn } from 'child_process';
import { registry, Executable } from '../utils/registry';
import { spawnAsync, getPlaywrightVersion } from '../utils/utils';
import { launchGridAgent } from '../grid/gridAgent';
import { launchGridServer } from '../grid/gridServer';
import { GridServer, GridFactory } from '../grid/gridServer';
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('--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());
}

View 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();
});
}

View file

@ -15,7 +15,6 @@
*/
import debug from 'debug';
import path from 'path';
import assert from 'assert';
import { EventEmitter } from 'events';
import { URL } from 'url';
@ -32,7 +31,8 @@ export type GridAgentLaunchOptions = {
export type GridFactory = {
name?: string,
capacity?: number,
timeout?: number,
launchTimeout?: number,
retireTimeout?: number,
launch: (launchOptions: GridAgentLaunchOptions) => Promise<void>,
};
@ -44,10 +44,10 @@ type ErrorCode = {
const WSErrors = {
NO_ERROR: { code: 1000, reason: '' },
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_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' },
CLIENT_SOCKET_ERROR: { code: 1011, reason: 'Grid client 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>();
private _status: AgentStatus = 'none';
private _workersWaitingForAgentConnected: GridWorker[] = [];
private _retireTimeout: NodeJS.Timeout | undefined;
private _retireTimeout = 30000;
private _retireTimeoutId: NodeJS.Timeout | undefined;
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();
this._capacity = capacity;
this._log = debug(`[agent ${this.agentId}]`);
this.setStatus('created');
this._agentCreationTimeout = setTimeout(() => {
this._retireTimeout = retireTimeout;
this._agentCreationTimeoutId = setTimeout(() => {
this.closeAgent(WSErrors.AGENT_CREATION_TIMED_OUT);
}, creationTimeout);
}
@ -126,7 +128,7 @@ class GridAgent extends EventEmitter {
}
agentConnected(ws: WebSocket) {
clearTimeout(this._agentCreationTimeout);
clearTimeout(this._agentCreationTimeoutId);
this.setStatus('connected');
this._ws = ws;
for (const worker of this._workersWaitingForAgentConnected) {
@ -141,8 +143,8 @@ class GridAgent extends EventEmitter {
}
async createWorker(clientSocket: WebSocket) {
if (this._retireTimeout)
clearTimeout(this._retireTimeout);
if (this._retireTimeoutId)
clearTimeout(this._retireTimeoutId);
if (this._ws)
this.setStatus('connected');
const worker = new GridWorker(clientSocket);
@ -152,9 +154,10 @@ class GridAgent extends EventEmitter {
this._workers.delete(worker.workerId);
if (!this._workers.size) {
this.setStatus('retiring');
if (this._retireTimeout)
clearTimeout(this._retireTimeout);
this._retireTimeout = setTimeout(() => this.closeAgent(WSErrors.AGENT_RETIRED), 30000);
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) {
@ -182,7 +185,7 @@ class GridAgent extends EventEmitter {
}
}
class GridServer {
export class GridServer {
private _server: HttpServer;
private _wsServer: WebSocketServer;
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);
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) {
ws.close(WSErrors.AGENT_CREATION_FAILED.code, WSErrors.AGENT_CREATION_FAILED.reason);
return;
@ -282,25 +285,32 @@ class GridServer {
});
}
private _createAgent(): GridAgent {
const agent = new GridAgent(this._factory.capacity, this._factory.timeout);
public async createAgent() {
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);
agent.on('close', () => {
this._agents.delete(agent.agentId);
});
Promise.resolve()
const initPromise = Promise.resolve()
.then(() => this._factory.launch({
agentId: agent.agentId,
gridURL: this._server.urlPrefix(),
playwrightVersion: getPlaywrightVersion(),
})).then(() => {
this._log('created');
}).catch(e => {
return { success: true, error: undefined };
}).catch(error => {
this._log('failed to launch agent ' + agent.agentId);
console.error(e);
console.error(error);
agent.closeAgent(WSErrors.AGENT_CREATION_FAILED);
return { success: false, error };
});
return agent;
return { agent, initPromise };
}
_securePath(suffix: string): string {
@ -338,7 +348,7 @@ class GridServer {
`;
}
async start(port: number) {
async start(port?: number) {
await this._server.start(port);
}
@ -357,21 +367,3 @@ class GridServer {
function mangle(sessionId: string) {
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());
}

View file

@ -15,22 +15,26 @@
*/
import child_process from 'child_process';
import { GridAgentLaunchOptions } from './gridServer';
import { GridAgentLaunchOptions, GridFactory } from './gridServer';
import path from 'path';
export const name = 'Agents co-located with grid';
export const capacity = Infinity;
export const timeout = 10000;
export function launch({ agentId, gridURL }: GridAgentLaunchOptions) {
child_process.spawn(process.argv[0], [
path.join(__dirname, '..', 'cli', 'cli.js'),
'experimental-grid-agent',
'--grid-url', gridURL,
'--agent-id', agentId,
], {
cwd: __dirname,
shell: true,
stdio: 'inherit',
});
}
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;

View file

@ -25,6 +25,9 @@ import { stopProfiling, startProfiling } from './profiler';
import { FilePatternFilter } from './util';
import { Loader } from './loader';
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 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);
if (process.env.PLAYWRIGHT_DOCKER)
runner.addInternalGlobalSetup(launchDockerContainer);
const result = await runner.run(!!opts.list, filePatternFilters, opts.project || undefined);
await stopProfiling(undefined);
@ -225,3 +230,12 @@ function resolveReporter(id: string) {
return localPath;
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();
}

View file

@ -56,10 +56,13 @@ type RunResult = {
clashingTests: Map<string, TestCase[]>
};
type InternalGlobalSetupFunction = () => Promise<() => Promise<void>>;
export class Runner {
private _loader: Loader;
private _reporter!: Reporter;
private _didBegin = false;
private _internalGlobalSetups: Array<InternalGlobalSetupFunction> = [];
constructor(loader: Loader) {
this._loader = loader;
@ -96,6 +99,10 @@ export class Runner {
return new Multiplexer(reporters);
}
addInternalGlobalSetup(internalGlobalSetup: InternalGlobalSetupFunction) {
this._internalGlobalSetups.push(internalGlobalSetup);
}
async run(list: boolean, filePatternFilters: FilePatternFilter[], projectNames?: string[]): Promise<RunResultStatus> {
this._reporter = await this._createReporter(list);
const config = this._loader.fullConfig();
@ -187,6 +194,11 @@ export class Runner {
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;
let globalSetupResult: any;
if (config.globalSetup && !list)
@ -338,6 +350,8 @@ export class Runner {
if (config.globalTeardown && !list)
await (await this._loader.loadGlobalHook(config.globalTeardown, 'globalTeardown'))(this._loader.fullConfig());
await webServer?.kill();
for (const internalGlobalTeardown of internalGlobalTeardowns)
await internalGlobalTeardown();
}
}
}

View file

@ -1,5 +1,8 @@
FROM ubuntu:bionic
ARG DEBIAN_FRONTEND=noninteractive
ARG TZ=America/Los_Angeles
# === INSTALL Node.js ===
# 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/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 ===
ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
ENV DEBIAN_FRONTEND=noninteractive
# 1. Add tip-of-tree Playwright package to install its browsers.
# The package should be built beforehand from tip-of-tree Playwright.
COPY ./playwright-core.tar.gz /tmp/playwright-core.tar.gz
# 2. Install playwright and then delete the installation.
# Browsers will remain downloaded in `/ms-playwright`.
# 2. Install playwright agent.
# Browsers will be downloaded in `/ms-playwright`.
# Note: make sure to set 777 to the registry so that any user can access
# registry.
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 && \
npx playwright install && \
DEBIAN_FRONTEND=noninteractive npx playwright install-deps && \
rm -rf /tmp/pw && rm /tmp/playwright-core.tar.gz && \
npx playwright install --with-deps && \
rm /tmp/playwright-core.tar.gz && \
chmod -R 777 /ms-playwright
COPY start_agent.sh /ms-playwright-agent/start_agent.sh

View file

@ -1,5 +1,8 @@
FROM ubuntu:focal
ARG DEBIAN_FRONTEND=noninteractive
ARG TZ=America/Los_Angeles
# === INSTALL Node.js ===
# 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/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 ===
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.
COPY ./playwright-core.tar.gz /tmp/playwright-core.tar.gz
# 2. Install playwright and then delete the installation.
# Browsers will remain downloaded in `/ms-playwright`.
# 2. Install playwright agent.
# Browsers will be downloaded in `/ms-playwright`.
# Note: make sure to set 777 to the registry so that any user can access
# registry.
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 && \
npx playwright install && \
DEBIAN_FRONTEND=noninteractive npx playwright install-deps && \
rm -rf /tmp/pw && rm /tmp/playwright-core.tar.gz && \
npx playwright install --with-deps && \
rm /tmp/playwright-core.tar.gz && \
chmod -R 777 /ms-playwright
COPY start_agent.sh /ms-playwright-agent/start_agent.sh

View 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"