Skip to content

Commit

Permalink
feat: introduce docker integration
Browse files Browse the repository at this point in the history
This patch introduces the following commands:
- `npx playwright install docker-image` that builds a VRT docker image
  locally that is based off the `mcr.microsoft.com/playwright:focal`
- `npx playwright start-docker` that launches a docker container with
  browsers.
- `npx playwright stop-docker` that stops given docker container.
- `npx playwright test --docker` that runs all the tests inside a
  launched docker container.
  • Loading branch information
aslushnikov committed Aug 9, 2022
1 parent 53917f4 commit 8a9cb3d
Show file tree
Hide file tree
Showing 6 changed files with 307 additions and 11 deletions.
1 change: 1 addition & 0 deletions packages/playwright-core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
},
"./cli": "./cli.js",
"./package.json": "./package.json",
"./lib/cli/docker": "./lib/cli/docker.js",
"./lib/grid/gridServer": "./lib/grid/gridServer.js",
"./lib/outofprocess": "./lib/outofprocess.js",
"./lib/utils": "./lib/utils/index.js",
Expand Down
23 changes: 15 additions & 8 deletions packages/playwright-core/src/cli/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import { spawnAsync } from '../utils/spawnAsync';
import { launchGridAgent } from '../grid/gridAgent';
import type { GridFactory } from '../grid/gridServer';
import { GridServer } from '../grid/gridServer';
import * as docker from './docker';
import type { Executable } from '../server';
import { registry, writeDockerVersion } from '../server';

Expand Down Expand Up @@ -151,14 +152,8 @@ program
} else {
const installDockerImage = args.some(arg => arg === 'docker-image');
args = args.filter(arg => arg !== 'docker-image');
if (installDockerImage) {
const imageName = `mcr.microsoft.com/playwright:v${getPlaywrightVersion()}-focal`;
const { code } = await spawnAsync('docker', ['pull', imageName], { stdio: 'inherit' });
if (code !== 0) {
console.log('Failed to pull docker image');
process.exit(1);
}
}
if (installDockerImage)
await docker.buildVRTDockerImage();

const executables = checkBrowsersToInstall(args);
if (options.withDeps)
Expand Down Expand Up @@ -276,6 +271,18 @@ program
runServer(options.port ? +options.port : undefined, options.path, options.maxClients ? +options.maxClients : Infinity, options.socksProxy, options.reuseBrowser).catch(logErrorAndExit);
});

program
.command('start-docker', { hidden: true })
.action(function(options) {
docker.startVRTService();
});

program
.command('stop-docker', { hidden: true })
.action(function(options) {
docker.stopVRTService();
});

program
.command('print-api-json', { hidden: true })
.action(function(options) {
Expand Down
271 changes: 271 additions & 0 deletions packages/playwright-core/src/cli/docker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,271 @@
/**
* 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 * as utils from '../utils';
import { getPlaywrightVersion } from '../common/userAgent';

interface DockerImage {
Containers: number;
Created: number;
Id: string;
Labels: null | Record<string, string>;
ParentId: string;
RepoDigests: null | string[];
RepoTags: null | string[];
SharedSize: number;
Size: number;
VirtualSize: number;
}

const PW_CONTAINER_NAME = 'playwright-' + getPlaywrightVersion();
const BASE_IMAGE_NAME = getPlaywrightVersion().includes('next') ?
'mcr.microsoft.com/playwright:next-focal' :
`mcr.microsoft.com/playwright:v${getPlaywrightVersion()}-focal`;
const VRT_IMAGE_NAME = `mcr.microsoft.com/playwright:v${getPlaywrightVersion()}-vrt`;

export async function buildVRTDockerImage() {
await ensureDockerIsRunning();
let baseImageName = BASE_IMAGE_NAME;
if (process.env.PWTEST_IMAGE_NAME) {
baseImageName = process.env.PWTEST_IMAGE_NAME;
} else {
// 1. Pull base docker image
console.log(`Pulling ${BASE_IMAGE_NAME}...`);
await callDockerAPI('post', `/images/create?fromImage=${encodeURIComponent(BASE_IMAGE_NAME)}`);
}
// 2. Find pulled docker image
const dockerImage = await findDockerImage(baseImageName);
if (!dockerImage)
throw new Error(`Failed to pull ${baseImageName}`);
// 3. Launch container and install VNC in it
const NOVNC_REF = '1.2.0';
const WEBSOCKIFY_REF = '0.10.0';
console.log(`Building ${VRT_IMAGE_NAME}...`);
const containerId = await launchContainer(dockerImage, undefined, ['/bin/bash', '-c', `
# Install VNC & noVNC
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
`], false /* autoRemove */);
await postJSON(`/containers/${containerId}/wait`);

// 4. Commit a new image based on the launched container with installed VNC & noVNC.
const [ vrtRepo, vrtTag ] = VRT_IMAGE_NAME.split(':');
await postJSON(`/commit?container=${containerId}&repo=${vrtRepo}&tag=${vrtTag}`, {
Env: [
'DISPLAY_NUM=99',
'DISPLAY=:99',
],
});
await Promise.all([
// Make sure to wait for the container to be removed.
postJSON(`/containers/${containerId}/wait?condition=removed`),
callDockerAPI('delete', `/containers/${containerId}`),
]);
console.log(`Done!`);
}

export async function wsEndpoint(): Promise<string|undefined> {
await ensureDockerIsRunning();
const rawLogs = await callDockerAPI('get', `/containers/${PW_CONTAINER_NAME}/logs?stdout=true&stderr=true`).catch(e => {
throw new Error('\n' + utils.wrapInASCIIBox([
`Playwright docker container is not running.`,
`Launch first using this command:`,
``,
` npx playwright start-docker`,
``,
].join('\n'), 1));
});
if (!rawLogs)
return undefined;
// Docker prefixes every log line with 8 characters. Stip them out.
// See https://github.com/moby/moby/issues/7375
const logLines = rawLogs.split('\n').map(line => line.substring(8));
const LINE_PREFIX = 'Listening on ws://';
const webSocketLine = logLines.find(line => line.startsWith(LINE_PREFIX));
return webSocketLine ? 'ws://' + webSocketLine.substring(LINE_PREFIX.length) : undefined;
}

export async function startVRTService() {
await ensureDockerIsRunning();
const pwImage = await findDockerImage(VRT_IMAGE_NAME);
if (!pwImage) {
throw new Error(`\n` + utils.wrapInASCIIBox([
`Failed to find ${VRT_IMAGE_NAME} docker image.`,
`Please install docker image with the following command:`,
``,
` npx playwright install docker-image`,
``,
`<3 Playwright Team`,
].join('\n'), 1));
}

const containerId = await launchContainer(pwImage, PW_CONTAINER_NAME, [ '/bin/bash', '-c', `
set -e
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 &
cd /ms-playwright-agent
npx playwright run-server --port=5400 --path=/${utils.createGuid()}
`
], true /* autoRemove */, [ 5400, 7900 ] /* portForwarding */);

// Wait for the service to become available.
const startTime = Date.now();
let endpoint = undefined;
const timeouts = [0, 100, 100, 200, 500, 1000];
do {
await new Promise(x => setTimeout(x, timeouts.shift() ?? 1000));
endpoint = await wsEndpoint();
} while (!endpoint && Date.now() < startTime + 60000);

if (!endpoint)
throw new Error('Failed to launch docker container!');

/* eslint-disable no-console */
console.log(`✨ Launched Playwright ${getPlaywrightVersion()} Docker Container ✨`);
console.log(`- VNC session: http://localhost:7900?path=${utils.createGuid()}&resize=scale`);
}

export async function stopVRTService() {
await ensureDockerIsRunning();
await Promise.all([
// Make sure to wait for the container to be removed.
postJSON(`/containers/${PW_CONTAINER_NAME}/wait?condition=removed`),
postJSON(`/containers/${PW_CONTAINER_NAME}/kill`),
]);
}

async function ensureDockerIsRunning() {
try {
await callDockerAPI('get', '/info');
} catch (e) {
throw new Error('\n' + utils.wrapInASCIIBox([
`Docker is not running!`,
`Please install if necessary and launch docker service first:`,
``,
` https://docs.docker.com/`,
``,
].join('\n'), 1));
}
}

async function findDockerImage(imageName: string): Promise<DockerImage|undefined> {
const images: DockerImage[] | null = await getJSON('/images/json');
return images ? images.find(image => image.RepoTags?.includes(imageName)) : undefined;
}

async function launchContainer(dockerImage: DockerImage, containerName: string | undefined, command: string[], autoRemove: boolean, portsToForward: Number[] = []): Promise<string> {
const ExposedPorts: any = {};
const PortBindings: any = {};
for (const port of portsToForward) {
ExposedPorts[`${port}/tcp`] = {};
PortBindings[`${port}/tcp`] = [{ HostPort: port + ''}];
}
const container = await postJSON(`/containers/create` + (containerName ? `?name=${containerName}` : ''), {
Cmd: command,
AttachStdout: true,
AttachStderr: true,
Image: dockerImage.Id,
ExposedPorts,
HostConfig: {
Init: true,
AutoRemove: autoRemove,
ShmSize: 2 * 1024 * 1024 * 1024,
PortBindings,
},
});
await postJSON(`/containers/${container.Id}/start`);
return container.Id;
}

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'|'delete', url: string, body: Buffer|string|undefined = undefined): Promise<string> {
const dockerSocket = process.platform === 'win32' ? '\\\\.\\pipe\\docker_engine' : '/var/run/docker.sock';
const API_VERSION = '1.41';
return new Promise((resolve, reject) => {
const request = http.request({
socketPath: dockerSocket,
path: `/v${API_VERSION}${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) {
reject(new Error(`${method} ${url} FAILED with statusCode ${response.statusCode} and body\n${body}`));
} else {
resolve(body);
}
});
});
request.on('error', function(e){
reject(e);
});
if (body) {
request.setHeader('Content-Type', 'application/json');
request.setHeader('Content-Length', body.length);
request.write(body);
} else {
request.setHeader('Content-Type', 'text/plain');
}
request.end();
});
}
11 changes: 11 additions & 0 deletions packages/playwright-test/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
/* eslint-disable no-console */

import type { Command } from 'playwright-core/lib/utilsBundle';
import * as docker from 'playwright-core/lib/cli/docker';
import fs from 'fs';
import url from 'url';
import path from 'path';
Expand Down Expand Up @@ -51,6 +52,7 @@ function addTestCommand(program: Command) {
command.option('--max-failures <N>', `Stop after the first N failures`);
command.option('--output <dir>', `Folder for output artifacts (default: "test-results")`);
command.option('--quiet', `Suppress stdio`);
command.option('--docker', `Launch tests inside docker container`);
command.option('--repeat-each <N>', `Run each test N times (default: 1)`);
command.option('--reporter <reporter>', `Reporter to use, comma-separated, can be ${builtInReporters.map(name => `"${name}"`).join(', ')} (default: "${baseFullConfig.reporter[0]}")`);
command.option('--retries <retries>', `Maximum retry count for flaky tests, zero for no retries (default: no retries)`);
Expand Down Expand Up @@ -124,6 +126,15 @@ async function runTests(args: string[], opts: { [key: string]: any }) {
});
}

if (opts.docker) {
const wsEndpoint = await docker.wsEndpoint();
if (!wsEndpoint) {
throw new Error(`Playwright docker container is not running; run "npx playwright start-docker" first`);
}
process.env.PW_TEST_CONNECT_WS_ENDPOINT = await docker.wsEndpoint();
process.env.PW_TEST_IS_DOCKER = '1';
}

if (opts.headed || opts.debug)
overrides.use = { headless: false };
if (opts.debug) {
Expand Down
7 changes: 6 additions & 1 deletion packages/playwright-test/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ type WorkerFixtures = PlaywrightWorkerArgs & PlaywrightWorkerOptions & {
_browserOptions: LaunchOptions;
_artifactsDir: () => string;
_snapshotSuffix: string;
_isDocker: boolean;
};

export const test = _baseTest.extend<TestFixtures, WorkerFixtures>({
Expand Down Expand Up @@ -238,7 +239,11 @@ export const test = _baseTest.extend<TestFixtures, WorkerFixtures>({
});
},

_snapshotSuffix: [process.platform, { scope: 'worker' }],
_isDocker: [!!process.env.PW_TEST_IS_DOCKER, { scope: 'worker' }],

_snapshotSuffix: [async ({ _isDocker }, use, testInfo) => {
await use(_isDocker ? 'docker' : process.platform);
}, { scope: 'worker' }],

_setupContextOptionsAndArtifacts: [async ({ playwright, _snapshotSuffix, _combinedContextOptions, _browserOptions, _artifactsDir, trace, screenshot, actionTimeout, navigationTimeout }, use, testInfo) => {
testInfo.snapshotSuffix = _snapshotSuffix;
Expand Down
5 changes: 3 additions & 2 deletions utils/docker/Dockerfile.focal
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@ 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. Bake in browsers & deps.
# 2. Bake in Playwright Agent.
# Playwright Agent is used to bake in browsers and browser dependencies,
# and run docker server later on.
# Browsers will be downloaded in `/ms-playwright`.
# Note: make sure to set 777 to the registry so that any user can access
# registry.
Expand All @@ -38,5 +40,4 @@ RUN mkdir /ms-playwright && \
npx playwright mark-docker-image "${DOCKER_IMAGE_NAME_TEMPLATE}" && \
npx playwright install --with-deps && rm -rf /var/lib/apt/lists/* && \
rm /tmp/playwright-core.tar.gz && \
rm -rf /ms-playwright-agent && \
chmod -R 777 /ms-playwright

0 comments on commit 8a9cb3d

Please sign in to comment.