diff --git a/docs/src/docker.md b/docs/src/docker.md index dee119bcb4..7d4a4a43b4 100644 --- a/docs/src/docker.md +++ b/docs/src/docker.md @@ -145,3 +145,50 @@ The image will be tagged as `playwright:localbuild-focal` and could be run as: ``` docker run --rm -it playwright:localbuild /bin/bash ``` + +## (Experimental) Playwright Test Docker Integration +* langs: js + +Playwright Test now ships an **experimental** Docker integration. +With this integration, **only** browser binaries are running inside a Docker container, +while all the code is still running on the host operating system. + +Docker container provides a consistent environment, eliminating browser rendering +differences across platforms. Playwright Test will automatically proxy host network traffic +into the container, so browsers can access servers running on the host. + +:::note +Docker integration requires Docker installed & running on your computer. +See https://docs.docker.com/get-docker/ +::: + +:::note +If you use [Docker Desktop](https://www.docker.com/products/docker-desktop/), make sure to increase +default CPU and mem limit for better performance. +::: + +Docker integration usage: + +1. Build a local Docker image that will be used to run containers. This step + needs to be done only once. + ```bash js + npx playwright docker build + ``` + +2. Run Docker container in the background. + ```bash js + npx playwright docker start + ``` + +3. Run tests inside Docker container. Note that this command accepts all the same arguments + as a regular `npx playwright test` command. + ```bash js + npx playwright docker test + ``` + + Note that this command will detect running Docker container, and auto-launch it if needed. + +4. Finally, stop Docker container when it is no longer needed. + ```bash js + npx playwright docker stop + ``` diff --git a/packages/playwright-core/package.json b/packages/playwright-core/package.json index 9e1a6f4d90..425bed4eff 100644 --- a/packages/playwright-core/package.json +++ b/packages/playwright-core/package.json @@ -23,6 +23,7 @@ "./lib/grid/gridServer": "./lib/grid/gridServer.js", "./lib/outofprocess": "./lib/outofprocess.js", "./lib/utils": "./lib/utils/index.js", + "./lib/common/userAgent": "./lib/common/userAgent.js", "./lib/utils/comparators": "./lib/utils/comparators.js", "./lib/utils/eventsHelper": "./lib/utils/eventsHelper.js", "./lib/utils/fileUtils": "./lib/utils/fileUtils.js", diff --git a/packages/playwright-core/src/cli/cli.ts b/packages/playwright-core/src/cli/cli.ts index fe7486bc55..ed315e4506 100755 --- a/packages/playwright-core/src/cli/cli.ts +++ b/packages/playwright-core/src/cli/cli.ts @@ -291,7 +291,7 @@ program program .command('show-trace [trace...]') .option('-b, --browser ', 'browser to use, one of cr, chromium, ff, firefox, wk, webkit', 'chromium') - .description('Show trace viewer') + .description('show trace viewer') .action(function(traces, options) { if (options.browser === 'cr') options.browser = 'chromium'; diff --git a/packages/playwright-test/src/DEPS.list b/packages/playwright-test/src/DEPS.list index c6fa7ed79c..fa00ac451a 100644 --- a/packages/playwright-test/src/DEPS.list +++ b/packages/playwright-test/src/DEPS.list @@ -1,5 +1,6 @@ [*] ./utilsBundle.ts +docker/ matchers/ reporters/ third_party/ diff --git a/packages/playwright-test/src/cli.ts b/packages/playwright-test/src/cli.ts index ac18ba6e74..46bd120d15 100644 --- a/packages/playwright-test/src/cli.ts +++ b/packages/playwright-test/src/cli.ts @@ -17,9 +17,11 @@ /* eslint-disable no-console */ import type { Command } from 'playwright-core/lib/utilsBundle'; +import * as docker from './docker/docker'; import fs from 'fs'; import url from 'url'; import path from 'path'; +import { colors } from 'playwright-core/lib/utilsBundle'; import { Runner, builtInReporters, kDefaultConfigFiles } from './runner'; import type { ConfigCLIOverrides } from './runner'; import { stopProfiling, startProfiling } from './profiler'; @@ -29,14 +31,67 @@ import { baseFullConfig, defaultTimeout, fileIsModule } from './loader'; import type { TraceMode } from './types'; export function addTestCommands(program: Command) { - addTestCommand(program); + addTestCommand(program, false /* isDocker */); addShowReportCommand(program); addListFilesCommand(program); + addDockerCommand(program); } -function addTestCommand(program: Command) { +function addDockerCommand(program: Command) { + const dockerCommand = program.command('docker') + .description(`run tests in Docker (EXPERIMENTAL)`); + + dockerCommand.command('build') + .description('build local docker image') + .action(async function(options) { + await docker.ensureDockerEngineIsRunningOrDie(); + await docker.buildImage(); + }); + + dockerCommand.command('start') + .description('start docker container') + .action(async function(options) { + await docker.ensureDockerEngineIsRunningOrDie(); + let info = await docker.containerInfo(); + if (!info) { + process.stdout.write(`Starting docker container... `); + const time = Date.now(); + info = await docker.ensureContainerOrDie(); + const deltaMs = (Date.now() - time); + console.log('Done in ' + (deltaMs / 1000).toFixed(1) + 's'); + } + console.log([ + `- VNC session: ${info.vncSession}`, + `- Run tests with browsers inside container:`, + ` npx playwright docker test`, + `- Stop container *manually* when it is no longer needed:`, + ` npx playwright docker stop`, + ].join('\n')); + }); + + dockerCommand.command('delete-image', { hidden: true }) + .description('delete docker image, if any') + .action(async function(options) { + await docker.ensureDockerEngineIsRunningOrDie(); + await docker.deleteImage(); + }); + + dockerCommand.command('stop') + .description('stop docker container') + .action(async function(options) { + await docker.ensureDockerEngineIsRunningOrDie(); + await docker.stopContainer(); + }); + + addTestCommand(dockerCommand, true /* isDocker */); +} + +function addTestCommand(program: Command, isDocker: boolean) { const command = program.command('test [test-filter...]'); - command.description('Run tests with Playwright Test'); + if (isDocker) + command.description('run tests with Playwright Test and browsers inside docker container'); + else + command.description('run tests with Playwright Test'); command.option('--browser ', `Browser to use for tests, one of "all", "chromium", "firefox" or "webkit" (default: "chromium")`); command.option('--headed', `Run tests in headed browsers (default: headless)`); command.option('--debug', `Run tests with Playwright Inspector. Shortcut for "PWDEBUG=1" environment variable and "--timeout=0 --maxFailures=1 --headed --workers=1" options`); @@ -64,6 +119,27 @@ function addTestCommand(program: Command) { command.option('-x', `Stop after the first failure`); command.action(async (args, opts) => { try { + if (isDocker && !process.env.PW_TS_ESM_ON) { + console.log(colors.dim('Using docker container to run browsers.')); + await docker.ensureDockerEngineIsRunningOrDie(); + let info = await docker.containerInfo(); + if (!info) { + process.stdout.write(colors.dim(`Starting docker container... `)); + const time = Date.now(); + info = await docker.ensureContainerOrDie(); + const deltaMs = (Date.now() - time); + console.log(colors.dim('Done in ' + (deltaMs / 1000).toFixed(1) + 's')); + console.log(colors.dim('The Docker container will keep running after tests finished.')); + console.log(colors.dim('Stop manually using:')); + console.log(colors.dim(' npx playwright docker stop')); + } + console.log(colors.dim(`View screen: ${info.vncSession}`)); + process.env.PW_TEST_CONNECT_WS_ENDPOINT = info.wsEndpoint; + process.env.PW_TEST_CONNECT_HEADERS = JSON.stringify({ + 'x-playwright-proxy': '*', + }); + process.env.PW_TEST_SNAPSHOT_SUFFIX = 'docker'; + } await runTests(args, opts); } catch (e) { console.error(e); @@ -75,10 +151,10 @@ Arguments [test-filter...]: Pass arguments to filter test files. Each argument is treated as a regular expression. Examples: - $ npx playwright test my.spec.ts - $ npx playwright test some.spec.ts:42 - $ npx playwright test --headed - $ npx playwright test --browser=webkit`); + $ npx playwright${isDocker ? ' docker ' : ' '}test my.spec.ts + $ npx playwright${isDocker ? ' docker ' : ' '}test some.spec.ts:42 + $ npx playwright${isDocker ? ' docker ' : ' '}test --headed + $ npx playwright${isDocker ? ' docker ' : ' '}test --browser=webkit`); } function addListFilesCommand(program: Command) { diff --git a/packages/playwright-test/src/docker/build_docker_image.sh b/packages/playwright-test/src/docker/build_docker_image.sh new file mode 100644 index 0000000000..b592675995 --- /dev/null +++ b/packages/playwright-test/src/docker/build_docker_image.sh @@ -0,0 +1,74 @@ +export NOVNC_REF='1.3.0' +export WEBSOCKIFY_REF='0.10.0' +export DEBIAN_FRONTEND=noninteractive + +# Install FluxBox, 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 \ + && rm -rf noVNC-${NOVNC_REF}/{docs,tests} \ + && 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 \ + && rm -rf websockify-${WEBSOCKIFY_REF}/{docs,tests} \ + && mv websockify-${WEBSOCKIFY_REF} /opt/bin/noVNC/utils/websockify + +# Configure FluxBox menus +mkdir /root/.fluxbox +cd /ms-playwright-agent +cat <<'EOF' | node > /root/.fluxbox/menu + const { chromium, firefox, webkit } = require('playwright-core'); + + console.log(` + [begin] (fluxbox) + [submenu] (Browsers) {} + [exec] (Chromium) { ${chromium.executablePath()} --no-sandbox --test-type= } <> + [exec] (Firefox) { ${firefox.executablePath()} } <> + [exec] (WebKit) { ${webkit.executablePath()} } <> + [end] + [include] (/etc/X11/fluxbox/fluxbox-menu) + [end] + `); +EOF + +# Create entrypoint.sh +cat <<'EOF' > /entrypoint.sh +#!/bin/bash +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 500); 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/novnc_proxy --listen 7900 --vnc localhost:5900 >/dev/null 2>&1 & + +cd /ms-playwright-agent + +fbsetbg -c /ms-playwright-agent/node_modules/playwright-core/lib/server/chromium/appIcon.png + +NOVNC_UUID=$(cat /proc/sys/kernel/random/uuid) +echo "novnc is listening on http://127.0.0.1:7900?path=$NOVNC_UUID&resize=scale&autoconnect=1" + +PW_UUID=$(cat /proc/sys/kernel/random/uuid) +npx playwright run-server --port=5400 --path=/$PW_UUID +EOF +chmod 755 /entrypoint.sh diff --git a/packages/playwright-test/src/docker/docker.ts b/packages/playwright-test/src/docker/docker.ts new file mode 100644 index 0000000000..12eda03c2b --- /dev/null +++ b/packages/playwright-test/src/docker/docker.ts @@ -0,0 +1,303 @@ +/** + * 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. + */ +/* eslint-disable no-console */ + +import http from 'http'; +import path from 'path'; +import fs from 'fs'; +import { spawnAsync } from 'playwright-core/lib/utils/spawnAsync'; +import * as utils from 'playwright-core/lib/utils'; +import { getPlaywrightVersion } from 'playwright-core/lib/common/userAgent'; + +interface DockerImage { + Containers: number; + Created: number; + Id: string; + Labels: null | Record; + ParentId: string; + RepoDigests: null | string[]; + RepoTags: null | string[]; + SharedSize: number; + Size: number; + VirtualSize: number; +} + +const VRT_IMAGE_DISTRO = 'focal'; +const VRT_IMAGE_NAME = `playwright:local-${getPlaywrightVersion()}-${VRT_IMAGE_DISTRO}`; +const VRT_CONTAINER_NAME = `playwright-${getPlaywrightVersion()}-${VRT_IMAGE_DISTRO}`; + +export async function deleteImage() { + const dockerImage = await findDockerImage(VRT_IMAGE_NAME); + if (!dockerImage) + return; + + if (await containerInfo()) + await stopContainer(); + await callDockerAPI('delete', `/images/${dockerImage.Id}`); +} + +export async function buildImage() { + const isDevelopmentMode = getPlaywrightVersion().includes('next'); + let baseImageName = `mcr.microsoft.com/playwright:v${getPlaywrightVersion()}-${VRT_IMAGE_DISTRO}`; + // 1. Build or pull base image. + if (isDevelopmentMode) { + // Use our docker build scripts in development mode! + if (!process.env.PWTEST_DOCKER_BASE_IMAGE) { + const arch = process.arch === 'arm64' ? '--arm64' : '--amd64'; + console.error(utils.wrapInASCIIBox([ + `You are in DEVELOPMENT mode!`, + ``, + `1. Build local base image`, + ` ./utils/docker/build.sh ${arch} ${VRT_IMAGE_DISTRO} playwright:localbuild`, + `2. Use the local base to build VRT image:`, + ` PWTEST_DOCKER_BASE_IMAGE=playwright:localbuild npx playwright docker build`, + ].join('\n'), 1)); + process.exit(1); + } + baseImageName = process.env.PWTEST_DOCKER_BASE_IMAGE; + } else { + const { code } = await spawnAsync('docker', ['pull', baseImageName], { stdio: 'inherit' }); + if (code !== 0) + throw new Error('Failed to pull docker image!'); + } + // 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 + console.log(`Building ${VRT_IMAGE_NAME}...`); + const buildScriptText = await fs.promises.readFile(path.join(__dirname, 'build_docker_image.sh'), 'utf8'); + const containerId = await launchContainer({ + image: dockerImage, + autoRemove: false, + command: ['/bin/bash', '-c', buildScriptText], + }); + 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}`, { + Entrypoint: ['/entrypoint.sh'], + 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!`); +} + +interface ContainerInfo { + wsEndpoint: string; + vncSession: string; +} + +export async function containerInfo(): Promise { + const containerId = await findRunningDockerContainerId(); + if (!containerId) + return undefined; + const rawLogs = await callDockerAPI('get', `/containers/${containerId}/logs?stdout=true&stderr=true`).catch(e => ''); + if (!rawLogs) + return undefined; + // Docker might prefix every log line with 8 characters. Stip them out. + // See https://github.com/moby/moby/issues/7375 + // This doesn't happen if the containers is launched manually with attached terminal. + const logLines = rawLogs.split('\n').map(line => { + if ([0, 1, 2].includes(line.charCodeAt(0))) + return line.substring(8); + return line; + }); + const WS_LINE_PREFIX = 'Listening on ws://'; + const webSocketLine = logLines.find(line => line.startsWith(WS_LINE_PREFIX)); + const NOVNC_LINE_PREFIX = 'novnc is listening on '; + const novncLine = logLines.find(line => line.startsWith(NOVNC_LINE_PREFIX)); + return novncLine && webSocketLine ? { + wsEndpoint: 'ws://' + webSocketLine.substring(WS_LINE_PREFIX.length), + vncSession: novncLine.substring(NOVNC_LINE_PREFIX.length), + } : undefined; +} + +export async function ensureContainerOrDie(): Promise { + const pwImage = await findDockerImage(VRT_IMAGE_NAME); + if (!pwImage) { + console.error('\n' + utils.wrapInASCIIBox([ + `Failed to find local docker image.`, + `Please build local docker image with the following command:`, + ``, + ` npx playwright docker build`, + ``, + `<3 Playwright Team`, + ].join('\n'), 1)); + process.exit(1); + } + + let info = await containerInfo(); + if (info) + return info; + + await launchContainer({ + image: pwImage, + name: VRT_CONTAINER_NAME, + autoRemove: true, + ports: [5400, 7900], + }); + + // Wait for the service to become available. + const startTime = Date.now(); + const timeouts = [0, 100, 100, 200, 500, 1000]; + do { + await new Promise(x => setTimeout(x, timeouts.shift() ?? 1000)); + info = await containerInfo(); + } while (!info && Date.now() < startTime + 60000); + + if (!info) + throw new Error('Failed to launch docker container!'); + return info; +} + +export async function stopContainer() { + const containerId = await findRunningDockerContainerId(); + if (!containerId) + return; + await Promise.all([ + // Make sure to wait for the container to be removed. + postJSON(`/containers/${containerId}/wait?condition=removed`), + postJSON(`/containers/${containerId}/kill`), + ]); +} + +export async function ensureDockerEngineIsRunningOrDie() { + try { + await callDockerAPI('get', '/info'); + } catch (e) { + console.error(utils.wrapInASCIIBox([ + `Docker is not running!`, + `Please install and launch docker:`, + ``, + ` https://docs.docker.com/get-docker`, + ``, + ].join('\n'), 1)); + process.exit(1); + } +} + +async function findDockerImage(imageName: string): Promise { + const images: DockerImage[] | null = await getJSON('/images/json'); + return images ? images.find(image => image.RepoTags?.includes(imageName)) : undefined; +} + +interface Container { + ImageID: string; + State: string; + Names: [string]; + Id: string; +} + +async function findRunningDockerContainerId(): Promise { + const containers: (Container[]|undefined) = await getJSON('/containers/json'); + if (!containers) + return undefined; + const dockerImage = await findDockerImage(VRT_IMAGE_NAME); + const container = dockerImage ? containers.find((container: Container) => container.ImageID === dockerImage.Id) : undefined; + return container?.State === 'running' ? container.Id : undefined; +} + +interface ContainerOptions { + image: DockerImage; + autoRemove: boolean; + command?: string[]; + ports?: Number[]; + name?: string; +} + +async function launchContainer(options: ContainerOptions): Promise { + const ExposedPorts: any = {}; + const PortBindings: any = {}; + for (const port of (options.ports ?? [])) { + ExposedPorts[`${port}/tcp`] = {}; + PortBindings[`${port}/tcp`] = [{ HostPort: port + '' }]; + } + const container = await postJSON(`/containers/create` + (options.name ? '?name=' + options.name : ''), { + Cmd: options.command, + AttachStdout: true, + AttachStderr: true, + Image: options.image.Id, + ExposedPorts, + HostConfig: { + Init: true, + AutoRemove: options.autoRemove, + ShmSize: 2 * 1024 * 1024 * 1024, + PortBindings, + }, + }); + await postJSON(`/containers/${container.Id}/start`); + return container.Id; +} + +async function getJSON(url: string): Promise { + 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); +} + +const DOCKER_API_VERSION = '1.41'; + +function callDockerAPI(method: 'post'|'get'|'delete', url: string, body: Buffer|string|undefined = undefined): Promise { + const dockerSocket = process.platform === 'win32' ? '\\\\.\\pipe\\docker_engine' : '/var/run/docker.sock'; + return new Promise((resolve, reject) => { + const request = http.request({ + socketPath: dockerSocket, + path: `/v${DOCKER_API_VERSION}${url}`, + timeout: 30000, + 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(); + }); +} + diff --git a/packages/playwright-test/src/index.ts b/packages/playwright-test/src/index.ts index aadc12f9a6..f139aaaf8b 100644 --- a/packages/playwright-test/src/index.ts +++ b/packages/playwright-test/src/index.ts @@ -72,7 +72,10 @@ export const test = _baseTest.extend({ headless: [({ launchOptions }, use) => use(launchOptions.headless ?? true), { scope: 'worker', option: true }], channel: [({ launchOptions }, use) => use(launchOptions.channel), { scope: 'worker', option: true }], launchOptions: [{}, { scope: 'worker', option: true }], - connectOptions: [process.env.PW_TEST_CONNECT_WS_ENDPOINT ? { wsEndpoint: process.env.PW_TEST_CONNECT_WS_ENDPOINT } : undefined, { scope: 'worker', option: true }], + connectOptions: [process.env.PW_TEST_CONNECT_WS_ENDPOINT ? { + wsEndpoint: process.env.PW_TEST_CONNECT_WS_ENDPOINT, + headers: process.env.PW_TEST_CONNECT_HEADERS ? JSON.parse(process.env.PW_TEST_CONNECT_HEADERS) : undefined, + } : undefined, { scope: 'worker', option: true }], screenshot: ['off', { scope: 'worker', option: true }], video: ['off', { scope: 'worker', option: true }], trace: ['off', { scope: 'worker', option: true }], @@ -220,7 +223,7 @@ export const test = _baseTest.extend({ }); }, - _snapshotSuffix: [process.platform, { scope: 'worker' }], + _snapshotSuffix: [process.env.PW_TEST_SNAPSHOT_SUFFIX ?? process.platform, { scope: 'worker' }], _setupContextOptionsAndArtifacts: [async ({ playwright, _snapshotSuffix, _combinedContextOptions, _browserOptions, _artifactsDir, trace, screenshot, actionTimeout, navigationTimeout }, use, testInfo) => { testInfo.snapshotSuffix = _snapshotSuffix; diff --git a/tests/installation/docker-integration.spec.ts b/tests/installation/docker-integration.spec.ts new file mode 100755 index 0000000000..3cffac2ac2 --- /dev/null +++ b/tests/installation/docker-integration.spec.ts @@ -0,0 +1,130 @@ +/** + * 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 { test, expect } from './npmTest'; +import * as path from 'path'; +import * as fs from 'fs'; +import { TestServer } from '../../utils/testserver'; + +// Skipping docker tests on CI on non-linux since GHA does not have +// Docker engine installed on macOS and Windows. +test.skip(() => process.env.CI && process.platform !== 'linux'); + +test.beforeAll(async ({ exec }) => { + // Delete any previous docker image to ensure clean run. + await exec('npx playwright docker delete-image', { + cwd: path.join(__dirname, '..', '..'), + }); +}); + +test('make sure it tells to run `npx playwright docker build` when image is not instaleld', async ({ exec }) => { + await exec('npm i --foreground-scripts @playwright/test'); + const result = await exec('npx playwright docker test docker.spec.js', { + expectToExitWithError: true, + }); + expect(result).toContain('npx playwright docker build'); +}); + +test.describe('installed image', () => { + test.beforeAll(async ({ exec }) => { + await exec('npx playwright docker build', { + env: { PWTEST_DOCKER_BASE_IMAGE: 'playwright:installation-tests-focal' }, + cwd: path.join(__dirname, '..', '..'), + }); + }); + test.afterAll(async ({ exec }) => { + await exec('npx playwright docker delete-image', { + cwd: path.join(__dirname, '..', '..'), + }); + }); + + test('make sure it auto-starts container', async ({ exec }) => { + await exec('npm i --foreground-scripts @playwright/test'); + await exec('npx playwright docker stop'); + const result = await exec('npx playwright docker test docker.spec.js --grep platform'); + expect(result).toContain('@chromium Linux'); + }); + + test.describe('running container', () => { + test.beforeAll(async ({ exec }) => { + await exec('npx playwright docker start', { + cwd: path.join(__dirname, '..', '..'), + }); + }); + + test.afterAll(async ({ exec }) => { + await exec('npx playwright docker stop', { + cwd: path.join(__dirname, '..', '..'), + }); + }); + + test('all browsers work headless', async ({ exec }) => { + await exec('npm i --foreground-scripts @playwright/test'); + const result = await exec('npx playwright docker test docker.spec.js --grep platform --browser all'); + expect(result).toContain('@chromium Linux'); + expect(result).toContain('@webkit Linux'); + expect(result).toContain('@firefox Linux'); + }); + + test('all browsers work headed', async ({ exec }) => { + await exec('npm i --foreground-scripts @playwright/test'); + { + const result = await exec(`npx playwright docker test docker.spec.js --headed --grep userAgent --browser chromium`); + expect(result).toContain('@chromium'); + expect(result).not.toContain('Headless'); + expect(result).toContain(' Chrome/'); + } + { + const result = await exec(`npx playwright docker test docker.spec.js --headed --grep userAgent --browser webkit`); + expect(result).toContain('@webkit'); + expect(result).toContain(' Version/'); + } + { + const result = await exec(`npx playwright docker test docker.spec.js --headed --grep userAgent --browser firefox`); + expect(result).toContain('@firefox'); + expect(result).toContain(' Firefox/'); + } + }); + + test('screenshots have docker suffix', async ({ exec, tmpWorkspace }) => { + await exec('npm i --foreground-scripts @playwright/test'); + await exec('npx playwright docker test docker.spec.js --grep screenshot --browser all', { + expectToExitWithError: true, + }); + const files = await fs.promises.readdir(path.join(tmpWorkspace, 'docker.spec.js-snapshots')); + expect(files).toContain('img-chromium-docker.png'); + expect(files).toContain('img-firefox-docker.png'); + expect(files).toContain('img-webkit-docker.png'); + }); + + test('port forwarding works', async ({ exec, tmpWorkspace }) => { + await exec('npm i --foreground-scripts @playwright/test'); + const TEST_PORT = 8425; + const server = await TestServer.create(tmpWorkspace, TEST_PORT); + server.setRoute('/', (request, response) => { + response.end('Hello from host'); + }); + const result = await exec('npx playwright docker test docker.spec.js --grep localhost --browser all', { + env: { + TEST_PORT: TEST_PORT + '', + }, + }); + expect(result).toContain('@chromium Hello from host'); + expect(result).toContain('@webkit Hello from host'); + expect(result).toContain('@firefox Hello from host'); + }); + }); +}); + diff --git a/tests/installation/fixture-scripts/docker.spec.js b/tests/installation/fixture-scripts/docker.spec.js new file mode 100644 index 0000000000..edc16f1133 --- /dev/null +++ b/tests/installation/fixture-scripts/docker.spec.js @@ -0,0 +1,19 @@ +const { test, expect } = require('@playwright/test'); + +test('platform', async ({ page }) => { + console.log('@' + page.context().browser().browserType().name(), await page.evaluate(() => navigator.platform)); +}); + +test('userAgent', async ({ page }) => { + console.log('@' + page.context().browser().browserType().name(), await page.evaluate(() => navigator.userAgent)); +}); + +test('screenshot', async ({ page }) => { + await expect(page).toHaveScreenshot('img.png'); +}); + +test('localhost', async ({ page }) => { + expect(process.env.TEST_PORT).toBeTruthy(); + await page.goto('http://localhost:' + process.env.TEST_PORT); + console.log('@' + page.context().browser().browserType().name(), await page.textContent('body')); +}); diff --git a/tests/installation/globalSetup.ts b/tests/installation/globalSetup.ts index 936ecb0b13..0518517e65 100644 --- a/tests/installation/globalSetup.ts +++ b/tests/installation/globalSetup.ts @@ -21,40 +21,55 @@ import fs from 'fs'; import { TMP_WORKSPACES } from './npmTest'; const PACKAGE_BUILDER_SCRIPT = path.join(__dirname, '..', '..', 'utils', 'pack_package.js'); +const DOCKER_BUILDER_SCRIPT = path.join(__dirname, '..', '..', 'utils', 'docker', 'build.sh'); async function globalSetup() { await promisify(rimraf)(TMP_WORKSPACES); console.log(`Temporary workspaces will be created in ${TMP_WORKSPACES}. They will not be removed at the end. Set DEBUG=itest to determine which sub-dir a specific test is using.`); await fs.promises.mkdir(TMP_WORKSPACES, { recursive: true }); + if (process.env.PWTEST_INSTALLATION_TEST_SKIP_PACKAGE_BUILDS) { console.log('Skipped building packages. Unset PWTEST_INSTALLATION_TEST_SKIP_PACKAGE_BUILDS to build packages.'); - return; + } else { + console.log('Building packages. Set PWTEST_INSTALLATION_TEST_SKIP_PACKAGE_BUILDS to skip.'); + const outputDir = path.join(__dirname, 'output'); + await promisify(rimraf)(outputDir); + await fs.promises.mkdir(outputDir, { recursive: true }); + + const build = async (buildTarget: string, pkgNameOverride?: string) => { + const outPath = path.resolve(path.join(outputDir, `${buildTarget}.tgz`)); + const { code, stderr, stdout } = await spawnAsync('node', [PACKAGE_BUILDER_SCRIPT, buildTarget, outPath]); + if (!!code) + throw new Error(`Failed to build: ${buildTarget}:\n${stderr}\n${stdout}`); + console.log('Built:', pkgNameOverride || buildTarget); + return [pkgNameOverride || buildTarget, outPath]; + }; + + const builds = await Promise.all([ + build('playwright-core'), + build('playwright-test', '@playwright/test'), + build('playwright'), + build('playwright-chromium'), + build('playwright-firefox'), + build('playwright-webkit'), + ]); + + await fs.promises.writeFile(path.join(__dirname, '.registry.json'), JSON.stringify(Object.fromEntries(builds))); } - console.log('Building packages. Set PWTEST_INSTALLATION_TEST_SKIP_PACKAGE_BUILDS to skip.'); - const outputDir = path.join(__dirname, 'output'); - await promisify(rimraf)(outputDir); - await fs.promises.mkdir(outputDir, { recursive: true }); - - const build = async (buildTarget: string, pkgNameOverride?: string) => { - const outPath = path.resolve(path.join(outputDir, `${buildTarget}.tgz`)); - const { code, stderr, stdout } = await spawnAsync('node', [PACKAGE_BUILDER_SCRIPT, buildTarget, outPath]); + if (process.env.CI && process.platform !== 'linux') { + console.log('Skipped building docker: docker tests are not supported on Windows and macOS Github Actions.'); + } else if (process.env.PWTEST_INSTALLATION_TEST_SKIP_DOCKER_BUILD) { + console.log('Skipped building docker. Unset PWTEST_INSTALLATION_TEST_SKIP_DOCKER_BUILD to build docker.'); + } else { + console.log('Building docker. Set PWTEST_INSTALLATION_TEST_SKIP_DOCKER_BUILD to skip.'); + const DOCKER_IMAGE_NAME = 'playwright:installation-tests-focal'; + const arch = process.arch === 'arm64' ? '--arm64' : '--amd64'; + const { code, stderr, stdout } = await spawnAsync('bash', [DOCKER_BUILDER_SCRIPT, arch, 'focal', DOCKER_IMAGE_NAME]); if (!!code) - throw new Error(`Failed to build: ${buildTarget}:\n${stderr}\n${stdout}`); - console.log('Built:', pkgNameOverride || buildTarget); - return [pkgNameOverride || buildTarget, outPath]; - }; - - const builds = await Promise.all([ - build('playwright-core'), - build('playwright-test', '@playwright/test'), - build('playwright'), - build('playwright-chromium'), - build('playwright-firefox'), - build('playwright-webkit'), - ]); - - await fs.promises.writeFile(path.join(__dirname, '.registry.json'), JSON.stringify(Object.fromEntries(builds))); + throw new Error(`Failed to build docker:\n${stderr}\n${stdout}`); + console.log('Built: docker image ', DOCKER_IMAGE_NAME); + } } export default globalSetup; diff --git a/utils/build/build.js b/utils/build/build.js index fa64cc871d..0a07a7e696 100644 --- a/utils/build/build.js +++ b/utils/build/build.js @@ -308,7 +308,7 @@ copyFiles.push({ }); copyFiles.push({ - files: 'packages/playwright-test/src/**/*.js', + files: 'packages/playwright-test/src/**/*.(js|sh)', from: 'packages/playwright-test/src', to: 'packages/playwright-test/lib', ignored: ['**/.eslintrc.js'] diff --git a/utils/docker/Dockerfile.focal b/utils/docker/Dockerfile.focal index 81bac7369c..f6db13a34a 100644 --- a/utils/docker/Dockerfile.focal +++ b/utils/docker/Dockerfile.focal @@ -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. @@ -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