-
Notifications
You must be signed in to change notification settings - Fork 3.7k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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
1 parent
53917f4
commit 8a9cb3d
Showing
6 changed files
with
307 additions
and
11 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | ||
}); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters