Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions docs/guides/06-testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,9 +85,15 @@ a path and `-g` instead of `test.only`:
yarn run e2e:docker yarn playwright test packages/components/select -g "single select"
```

Requires Docker with Compose v2. On Windows, Docker Engine installed inside WSL puts no `docker.exe`
on the Windows PATH, so run these commands from inside the WSL distribution rather than from
PowerShell.
Requires Docker with Compose v2. On Windows carrying Docker Engine inside WSL rather than Docker
Desktop, `docker.exe` is often missing from the Windows PATH altogether — the Linux binary cannot be
projected onto it — but the wrapper also falls back to WSL when a `docker.exe` is present yet broken
(no Compose v2 plugin, a stale install). Either way it forwards the run through `wsl.exe` and
translates the paths it passes, so the commands above work unchanged from PowerShell. It looks for
Docker inside WSL's default distribution; set `WSL_DISTRIBUTION` to a distribution name if Docker
lives elsewhere. That check only confirms the CLI and Compose v2 plugin are present, not that the
daemon itself is reachable — a stopped daemon, or a WSL user outside the `docker` group, still
surfaces later, when the actual `docker compose run` fails.

### Worker count

Expand Down
10 changes: 10 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,16 @@ module.exports = tseslint.config(
'**/dist',
'**/node_modules',
'**/coverage',
// Playwright's output, for the same reason as dist: the HTML report embeds the trace
// viewer, which is minified vendor code, and linting it produces ~90 errors and ~700
// warnings about someone else's bundle. playwright-report and test-results are mounted
// out of the container by tools/e2e/docker-compose.yml; playwright-report-docs is the
// docs-site smoke suite's report (playwright.docs.config.ts). All three appear as soon
// as anyone runs the relevant suite locally — and, like dist, never on the fresh
// checkout CI lints.
'**/playwright-report',
'**/playwright-report-docs',
'**/test-results',
// ignore Yarn's bundled release/plugin binaries (flat config lints .cjs by default,
// unlike the previous `--ext=.js,.ts,.html`)
'.yarn',
Expand Down
6 changes: 3 additions & 3 deletions packages/e2e/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,6 @@ yarn run e2e:docker
yarn run e2e:docker:update-snapshots
```

Requires Docker with Compose v2. On Windows, Docker Engine installed inside WSL puts no `docker.exe` on
the Windows PATH, so run these from inside the WSL distribution. Without a local Docker install,
comment `/approve-snapshots` on a pull request to regenerate the baselines in CI.
Requires Docker with Compose v2 — see [Testing → Visual regression tests and Docker](../../docs/guides/06-testing.md#visual-regression-tests-and-docker)
for what the wrapper does on Windows when Docker only runs inside WSL. Without a local Docker
install, comment `/approve-snapshots` on a pull request to regenerate the baselines in CI.
137 changes: 115 additions & 22 deletions tools/e2e/run.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,51 +21,132 @@

const { spawnSync } = require('node:child_process');
const { existsSync } = require('node:fs');
const { join } = require('node:path');
const { basename, join } = require('node:path');
const { devDependencies } = require('../../package.json');

const TIME_LABEL = 'Runtime';
const COMPOSE_FILE = join(__dirname, 'docker-compose.yml');
const COMPOSE_UPDATE_FILE = join(__dirname, 'docker-compose.update.yml');

const fail = (message) => {
console.error(message);
process.exit(1);
};

// Read straight from the manifest rather than from node_modules: CI runs this without an install
// step, so the packages are not on disk.
const version = devDependencies['@playwright/test'];

// The tag has to be exact. A range would either not resolve to a tag at all or, worse, resolve to
// an image whose browsers differ from the ones the lockfile installs.
if (!/^\d+\.\d+\.\d+$/.test(version)) {
console.error(
fail(
`Expected devDependencies["@playwright/test"] in package.json to be an exact version, got ${JSON.stringify(version)}.`
);
process.exit(1);
}

// How `docker` is reached. Normally the CLI is on PATH and this is all there is to it; the win32
// branch below can replace it with a hop through WSL.
let docker = { command: 'docker', prefix: [] };
let isForwardedToWsl = false;

// `wsl.exe -e` alone always targets whichever distribution is current default, and there is no way
// to detect the right one automatically when Docker lives in a different one.
const wslDistro = process.env.WSL_DISTRIBUTION;
const wslArgs = (exe) => [...(wslDistro ? ['-d', wslDistro] : []), '-e', exe];

// encoding rather than stdio: 'ignore' so a failure can be explained with the command's own stderr
// instead of a guess. Neither probe prints anything unless something goes wrong: spawnSync only
// pipes, it does not inherit.
const probeCompose = (runner) =>
spawnSync(runner.command, [...runner.prefix, 'compose', 'version'], { encoding: 'utf8' });

const describeFailure = (result) =>
result.error ? result.error.message : (result.stderr || '').trim() || `exited with status ${result.status}`;

// Both prerequisites are checked up front, because neither fails in a way that explains itself.
// A missing `docker` surfaces as a bare ENOENT from spawn; a Docker CLI without the v2 compose
// plugin — a machine carrying only the legacy `docker-compose` binary — spawns fine and exits
// non-zero, which is indistinguishable from a genuine test failure further down.
const compose = spawnSync('docker', ['compose', 'version'], { stdio: 'ignore' });

if (compose.error?.code === 'ENOENT') {
console.error(
'Could not find `docker` on PATH.' +
(process.platform === 'win32'
? '\nWith Docker Engine installed inside WSL there is no docker.exe on the Windows PATH, ' +
'so run this from inside the WSL distribution rather than from PowerShell.'
: '')
);
process.exit(1);
let compose = probeCompose(docker);

// On Windows, retry through WSL whenever the direct probe did not cleanly succeed — not only on
// ENOENT. `docker.exe` can also be present but broken (a stale Docker Desktop install, a CLI
// without the compose v2 plugin), and that deserves the same chance to fall through to a working
// WSL-hosted Engine as a missing binary does.
//
// There is no docker.exe for Win32 to find in the WSL-only case — /usr/bin/docker is a Linux ELF
// binary, and while WSL projects Windows executables into the distribution, nothing does the
// reverse — so wsl.exe is the only bridge. A `docker.cmd` shim on PATH would not help either: Node
// does not resolve .bat/.cmd from a non-shell spawn, so the probe above would still ENOENT. Neither
// would a native Windows CLI talking to the WSL daemon over TCP — it resolves the compose file's
// relative volumes into Windows paths and hands them to a Linux daemon, which cannot bind-mount
// `C:\...`. Translating at the wsl.exe boundary (see toDockerPath below) keeps every path
// Linux-side, where compose expects them.
if ((compose.error || compose.status !== 0) && process.platform === 'win32') {
const viaWsl = { command: 'wsl.exe', prefix: wslArgs('docker') };
const probe = probeCompose(viaWsl);

if (!probe.error && probe.status === 0) {
docker = viaWsl;
compose = probe;
isForwardedToWsl = true;
} else {
fail(
'Could not run `docker compose version`:\n' +
` docker: ${describeFailure(compose)}\n` +
` wsl.exe ${wslArgs('docker').join(' ')}: ${describeFailure(probe)}` +
(wslDistro
? ''
: '\nIf Docker lives in a non-default WSL distribution, set WSL_DISTRIBUTION to its name.')
);
}
}

// Not folded into the block above: on Windows a missing `docker` already got a chance via WSL, so
// by this point it either forwarded successfully or exited with the combined diagnosis. Off
// Windows there is nothing to fall back to, so a plain ENOENT is reported directly.
if (process.platform !== 'win32' && compose.error?.code === 'ENOENT') {
fail('Could not find `docker` on PATH.');
}

if (compose.error || compose.status !== 0) {
console.error(
fail(
'`docker compose` is unavailable. These scripts need Compose v2, which ships as a Docker\n' +
'CLI plugin; the standalone `docker-compose` v1 binary cannot read this configuration.'
);
process.exit(1);
}

if (isForwardedToWsl) {
console.info(
`Forwarding through wsl.exe to a Docker Engine inside WSL${wslDistro ? ` (distribution: ${wslDistro})` : ''}.`
);
}

// In forwarded mode every path on the command line is read by a Linux process, so the Win32 paths
// join() produced have to be translated. wslpath rather than a string replacement, because the mount
// root is configurable — automount.root in wsl.conf — and need not be /mnt. COMPOSE_FILE and
// COMPOSE_UPDATE_FILE are both direct children of __dirname (see above), so translating that
// directory once and joining the statically-known filename covers both without a second wsl.exe
// round trip.
let translatedDir;

const toDockerPath = (path) => {
if (!isForwardedToWsl) return path;

if (translatedDir === undefined) {
const translated = spawnSync('wsl.exe', [...wslArgs('wslpath'), '-u', __dirname], { encoding: 'utf8' });

if (translated.error || translated.status !== 0) {
fail(`Could not translate ${__dirname} into a WSL path: ${describeFailure(translated)}`);
}

translatedDir = translated.stdout.trim();
}

return `${translatedDir}/${basename(path)}`;
};

const args = process.argv.slice(2);

// Writing baselines back to the working tree needs the source mounted; a plain run does not, and
Expand All @@ -80,15 +161,28 @@ if (isUpdatingSnapshots) {
console.info('Mounting packages/components so updated baselines land in the working tree.');
}

const env = { ...process.env, PLAYWRIGHT_VERSION: version };

// docker-compose.yml reads all three, and in forwarded mode it is parsed by a process on the other
// side of the WSL boundary, which does not inherit the Win32 environment. WSLENV is what carries a
// variable across; /u marks it as travelling in that direction only. Names of variables that are
// not set are ignored, and an entry repeated from an existing WSLENV is harmless.
if (isForwardedToWsl) {
const forwarded = ['PLAYWRIGHT_VERSION', 'E2E_PLATFORM', 'PLAYWRIGHT_WORKERS'];

env.WSLENV = [env.WSLENV, ...forwarded.map((name) => `${name}/u`)].filter(Boolean).join(':');
}

console.time(TIME_LABEL);

const result = spawnSync(
'docker',
docker.command,
[
...docker.prefix,
'compose',
'--file',
COMPOSE_FILE,
...(isUpdatingSnapshots ? ['--file', COMPOSE_UPDATE_FILE] : []),
toDockerPath(COMPOSE_FILE),
...(isUpdatingSnapshots ? ['--file', toDockerPath(COMPOSE_UPDATE_FILE)] : []),
'run',
'--rm',
'--build',
Expand All @@ -97,15 +191,14 @@ const result = spawnSync(
],
{
stdio: 'inherit',
env: { ...process.env, PLAYWRIGHT_VERSION: version }
env
}
);

console.timeEnd(TIME_LABEL);

if (result.error) {
console.error(`Failed to run docker: ${result.error.message}`);
process.exit(1);
fail(`Failed to run \`${docker.command}\`: ${result.error.message}`);
}

// Only when there is something to open: the run can also fail before any test executes — a build
Expand Down