diff --git a/docs/guides/06-testing.md b/docs/guides/06-testing.md index 522bcb92c6..e1ec3b0b71 100644 --- a/docs/guides/06-testing.md +++ b/docs/guides/06-testing.md @@ -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 diff --git a/eslint.config.js b/eslint.config.js index c1945cc15d..ff18c41195 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -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', diff --git a/packages/e2e/README.md b/packages/e2e/README.md index 077482ce64..9cd102751c 100644 --- a/packages/e2e/README.md +++ b/packages/e2e/README.md @@ -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. diff --git a/tools/e2e/run.js b/tools/e2e/run.js index 51d6c9ff76..9f468e1ca3 100644 --- a/tools/e2e/run.js +++ b/tools/e2e/run.js @@ -21,13 +21,18 @@ 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']; @@ -35,37 +40,113 @@ 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 @@ -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', @@ -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