Running in CI/CD
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
An end-to-end suite earns its keep when it runs on every pull request, on a machine nobody is sitting at, and tells the author what broke without anyone re-running it by hand. That is a different environment from a laptop: no display, a fresh file system on every run, a clock that is billed by the minute, secrets that must not leak, and a web application that has to be started (or found) before the first test begins. This page walks through that environment for the three tools in this section, using GitHub Actions as the example provider. It targets Playwright 1.64, Cypress 16.2 and Selenium 4.51 and was checked on 25 October 2026. The tests are the small Bookshelf smoke suites described in Example Application & Test Layout.
How the workflows were checked matters, so it is stated up front. GitHub-hosted runners were not available, so every
workflow on this page was run with act 0.2.89, which executes a workflow’s jobs in Docker
containers on the developer machine, against a local Git repository holding the Bookshelf application and the three test
projects, on an Apple-silicon Mac (Linux arm64 containers). Each of those runs passed. act is not GitHub, though, and a
few lines in the examples had to be changed to make it work; the section Validating workflows with act lists every
difference. The Selenium Manager commands were run directly on the Mac, and a deployed preview was simulated with a
second local Bookshelf instance. YAML syntax of every file was also checked with a YAML parser. Nothing here has been run
on GitHub itself.
Provider-neutral ideas (one job per shard, cache the downloads, keep reports as artifacts) carry over to GitLab CI, Jenkins, CircleCI, Azure Pipelines and others; the tool vendors keep sample configurations for those. The container basics used below are covered in Running Containers and Docker CI/CD with GitHub Actions, and the Git side of triggers and checks in Pull Requests.
The shape of an E2E job
Whatever the tool, a CI run for browser tests has the same stages. The differences between Playwright, Cypress and Selenium are mostly in how each stage is spelled.
push, pull request,
deployment, manual"] --> C["Checkout
and install
(cached)"] C --> B["Browsers
image, cache or
service container"] B --> A["Application
started locally or
preview URL"] A --> R["Run tests
one job or
shards in parallel"] R --> P["Evidence
blob, screenshots,
traces, videos"] P --> M["Merge and publish
HTML report,
summary, annotations"] M --> G["Pull request
status check"]
Seven decisions recur in every pipeline and are worth making deliberately:
-
Where the browsers come from. Install them in the job (Playwright’s
install --with-deps), use an image that already contains them (Playwright, Cypress or Selenium images), or use the browsers preinstalled on the runner. -
Where the application comes from. Start it inside the job (a
webServerentry, the Cypress action’sstart, a background process) or test an already deployed preview. -
How the run is split. One job for a small suite, a matrix of shards for a large one.
-
What is cached. Package-manager downloads and browser binaries; never
node_modulesand never test results. -
What is kept. Reports, traces, screenshots and videos as artifacts, with a short retention.
-
Who may see secrets. Credentials of test users and record keys come from the provider’s secret store, and are absent from fork pull requests.
-
When to give up. A global timeout inside the tool, so that the runner never kills the job before a report exists.
Triggers, permissions and concurrency
Every workflow below starts the same way. push on the main branch and pull_request give a check on each change;
workflow_dispatch lets a person start the run with inputs; deployment_status reacts to a finished deployment (the
preview section uses it). Two small lines save money and noise:
permissions:
contents: read # the job needs to read the repository, nothing more
concurrency:
group: playwright-${{ github.ref }}
cancel-in-progress: true # a new push to a pull request cancels its older run
Source: GitHub Actions: Workflow syntax
permissions narrows the automatic GITHUB_TOKEN, which matters because the job runs code from the pull request.
concurrency with cancel-in-progress avoids spending runner minutes on a commit that has already been replaced. Do not
use it on the branch that publishes releases.
What the tools assume about CI
All three tools change behaviour when they believe they are in CI, and the setting is usually the environment variable
CI, which GitHub Actions sets to true on every runner.
| Aspect | Playwright | Cypress | Selenium |
|---|---|---|---|
Display |
Headless by default; no display needed |
|
Headless only if you pass the |
Retries and workers |
Set in the configuration, commonly |
|
Your runner (JUnit parallel execution, |
Timeout for the whole run |
|
Stop the job with the provider’s |
Provider’s |
Evidence |
Trace, screenshot, video per |
Screenshots on failure and optional video in |
Whatever your code writes (screenshots in a failure hook) |
Test-run summary for the pull request |
|
The action writes a job summary |
JUnit XML published by a test-report action or by the build tool |
The rest of this page expands each column.
Playwright
Playwright’s documentation lists three steps for any provider: make sure the agent can run browsers (the Docker image or
the CLI), install the packages and browsers, run npx playwright test. On GitHub Actions the npm init playwright@latest
scaffold produces a ready-made workflow; the version below is the same idea, adapted to the layout of the example
repository (a playwright folder next to the app folder), with a browser cache and a shard matrix.
Configuration for CI
The workflow depends on a few lines in playwright.config.ts. The ones that matter for CI are the reporter (a blob per
shard, plus the github reporter for annotations), workers, retries, forbidOnly, globalTimeout and the
webServer entry that starts the application only when no deployed URL is given:
// playwright/playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
const baseURL = process.env.BASE_URL ?? 'http://localhost:3000';
export default defineConfig({
testDir: './tests',
fullyParallel: true,
forbidOnly: !!process.env.CI, // fail the build if a test.only slipped in
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
// Stop the run itself so that reporters still produce a report.
globalTimeout: 20 * 60 * 1000,
// One blob per shard on CI (merged later), a readable list locally.
reporter: process.env.CI
? [['blob'], ['github']]
: [['list'], ['html', { open: 'never' }]],
use: { baseURL, testIdAttribute: 'data-testid', trace: 'on-first-retry' },
// Start the application only when no deployed URL is given.
webServer: process.env.BASE_URL
? undefined
: {
command: 'npm start',
cwd: '../app',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
],
});
Source: Playwright: Test configuration
A few details explain the choices:
-
workers: process.env.CI ? 1 : undefinedfollows the documentation’s advice to prioritise stability on CI. Speed comes from sharding across jobs rather than from many workers inside a small runner; raise the number only on a powerful self-hosted machine. See Parallelism, Sharding & Retries for the details. -
globalTimeoutis the documented replacement for a job-leveltimeout-minutes: when the runner kills a job, nothing is reported; when Playwright stops itself, the reporters still run. If you also settimeout-minutes, keep it comfortably aboveglobalTimeout. -
reuseExistingServer: !process.env.CIlets a developer keep a server running locally but makes CI always start its own, so a stale process cannot make tests pass. -
trace: 'on-first-retry'records a trace only for a test that failed once; the trace is the main artifact for diagnosing CI failures (see Debugging, Tracing & Codegen).
A GitHub Actions workflow with a shard matrix
The test job runs once per shard; the merge-reports job collects the blob reports and builds one HTML report:
name: Playwright
on:
push:
branches: [main]
pull_request:
# Cancel the previous run of the same pull request when a new commit arrives.
concurrency:
group: playwright-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-24.04
defaults:
run:
working-directory: playwright
strategy:
fail-fast: false # let every shard finish so the merged report is complete
matrix:
shardIndex: [1, 2]
shardTotal: [2]
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 24
cache: npm
cache-dependency-path: playwright/package-lock.json
- run: npm ci
# The browsers live outside node_modules; cache them per Playwright version.
- name: Read the Playwright version
id: playwright-version
run: echo "version=$(npm ls @playwright/test --json | jq -r '.dependencies["@playwright/test"].version')" >> "$GITHUB_OUTPUT"
- name: Cache the browsers
id: browsers
uses: actions/cache@v6
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ steps.playwright-version.outputs.version }}
- name: Install the browsers and their system libraries
if: steps.browsers.outputs.cache-hit != 'true'
run: npx playwright install --with-deps
- name: Install the system libraries only (browsers restored from cache)
if: steps.browsers.outputs.cache-hit == 'true'
run: npx playwright install-deps
- name: Run the tests of this shard
run: npx playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
env:
BOOKSHELF_READER_PASSWORD: ${{ secrets.BOOKSHELF_READER_PASSWORD }}
- name: Upload the blob report
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v7
with:
name: blob-report-${{ matrix.shardIndex }}
path: playwright/blob-report
retention-days: 1
merge-reports:
if: ${{ !cancelled() }}
needs: [test]
runs-on: ubuntu-24.04
defaults:
run:
working-directory: playwright
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 24
cache: npm
cache-dependency-path: playwright/package-lock.json
- run: npm ci
- uses: actions/download-artifact@v8
with:
path: playwright/all-blob-reports
pattern: blob-report-*
merge-multiple: true
- name: Merge into one HTML report and one summary
run: npx playwright merge-reports --reporter=html,github ./all-blob-reports
- uses: actions/upload-artifact@v7
with:
name: html-report--attempt-${{ github.run_attempt }}
path: playwright/playwright-report
retention-days: 14
What each part does:
-
npm ciinstalls exactly whatpackage-lock.jsonlists and fails if the lock file andpackage.jsondisagree.actions/setup-nodewithcache: npmcaches npm’s download cache (notnode_modules), keyed by the lock file. -
Installing browsers.
npx playwright install --with-depsdownloads Chromium, Firefox and WebKit builds that match the installed@playwright/testand installs the Linux libraries they need withapt. The browsers are large and the download is the slowest step of a cold run, hence theactions/cachestep keyed on the Playwright version. When the cache is restored, the browsers are there but the operating-system libraries are not (they are not in~/.cache/ms-playwright), so the job still runsnpx playwright install-deps. Both branches ran underact; the cached run restored the keyplaywright-linux-1.64.1and skipped the download. A different, simpler option is to use the Docker image shown below, where neither is needed. -
--shard=1/2runs the first half of the tests,--shard=2/2the second. WithfullyParallel: truethe shards are balanced by test, otherwise by file.fail-fast: falsematters: with the default, a failing shard cancels the others and the merged report is incomplete. -
if: ${{ !cancelled() }}uploads the blob even when the tests failed, which is exactly when it is needed.failure()would skip the upload for a run that only had flaky tests, whose traces are still useful;always()would also upload after a manual cancel. -
The
merge-reportsjob downloads every blob (merge-multiple: trueflattens them into one directory) and runsnpx playwright merge-reports. The reporter listhtml,githubproduces the HTML report and the GitHub annotations and summary in one step. The blob reporter is what makes this possible: each shard writes raw events, and the merge job turns all of them into one consistent report.
Under act, with two shards and the chromium and firefox projects, each shard printed
Running 3 tests using 1 worker, shard 1 of 2 (and 2 of 2), and the merge job reported all six tests as passed:
[Playwright/test-1] | Running 3 tests using 1 worker, shard 1 of 2
[Playwright/test-1] ❓ ::notice title=🎭 Playwright Run Summary:: 3 passed (2.3s)
[Playwright/test-2] | Running 3 tests using 1 worker, shard 2 of 2
[Playwright/test-2] ❓ ::notice title=🎭 Playwright Run Summary:: 3 passed (5.1s)
[Playwright/merge-reports] | merging reports from ...
[Playwright/merge-reports] | extracting: all-blob-reports/report-1.zip
[Playwright/merge-reports] | extracting: all-blob-reports/report-2.zip
[Playwright/merge-reports] ❓ ::notice title=🎭 Playwright Run Summary:: 6 passed (49.0s)
Source: Playwright: Sharding
The 49.0s is the sum of the test durations across both shards as recorded in the blobs, not the wall-clock time of the
job.
Viewing the HTML report
The HTML report is a static site, but it needs a web server to work: opening index.html from disk does not load the
traces. Download the artifact from the run page, extract it and serve it:
npx playwright show-report path/to/extracted-html-report
The report links each failing test to its trace. To publish reports on the web instead of downloading zip files, the
documentation describes uploading playwright-report to an Azure Storage static website from the workflow, with a service
principal stored as repository secrets. That variant needs an Azure subscription and is not covered by the example above;
steps that use secrets do not work for pull requests from forks.
Running changed tests first
For a faster feedback loop on pull requests, a preliminary run of only the test files affected by the change can fail early; the complete run still follows. It needs a full clone so that the base branch can be compared:
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0 # a shallow clone cannot be compared to the base branch
# ... setup, npm ci, playwright install ...
- name: Run changed Playwright tests
if: github.event_name == 'pull_request'
run: npx playwright test --only-changed=origin/$GITHUB_BASE_REF
- name: Run Playwright tests
run: npx playwright test
Source: Playwright: Fail-Fast
--only-changed analyses the dependency graph of the test files, which is a heuristic; it can miss tests, so the full run
must remain.
The Playwright Docker image
mcr.microsoft.com/playwright contains the Playwright browsers and the system libraries they need, built on Ubuntu. It
does not contain the Playwright package: your project installs that with npm ci, and the version must match. Pin the
image to the exact version of @playwright/test in the lock file, because a mismatch makes Playwright unable to find the
browser executables. Tags exist per Ubuntu release (-noble for 24.04, -jammy for 22.04, -resolute for 26.04), and
the images are published for amd64 and arm64. Alpine is not supported, because the Firefox and WebKit builds need glibc.
Running the image directly
The documentation recommends two flags for any container that runs browsers:
-
--initruns a small init process as PID 1, which prevents zombie processes (browsers spawn many children). -
--ipc=hostshares the host’s IPC namespace so Chromium does not run out of shared memory and crash. A more conservative alternative is a larger/dev/shmwith--shm-size.
docker run --rm --init --ipc=host \
-e CI=1 \
-e BASE_URL=http://host.docker.internal:3965 \
-v "$PWD":/work -w /work \
mcr.microsoft.com/playwright:v1.64.1-noble \
sh -c 'npm ci && npx playwright test --reporter=list'
Source: Playwright: Docker
Run on the Mac against a local Bookshelf (the container reaches the host through host.docker.internal), this printed
Version 1.64.1 and 6 passed (5.4s) for the chromium and firefox projects.
Which user
By default the image runs as root. That disables the Chromium sandbox (it needs user namespaces, which root in a
container lacks), which the documentation accepts for end-to-end tests of code you trust. For crawling or visiting
untrusted sites, use the unprivileged pwuser account (user and group id 1001 in the image) together with a seccomp
profile that allows user namespaces:
docker run -it --rm --ipc=host --user pwuser \
--security-opt seccomp=seccomp_profile.json \
mcr.microsoft.com/playwright:v1.64.1-noble /bin/bash
id inside the image confirmed uid=1001(pwuser), and that the browsers sit in /ms-playwright (chromium-1248,
firefox-1555, webkit-2370). The seccomp profile itself is published in the documentation; it was not exercised here.
The image is meant for testing and development, not for browsing untrusted sites.
As a job container
GitHub Actions can run a whole job inside the image with jobs.<job_id>.container. This keeps the host clean and gives
the same operating system every time, which is also what makes screenshot comparisons stable between runs (see
Visual Regression Testing):
name: Playwright in a container
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
jobs:
e2e:
runs-on: ubuntu-24.04
container:
# Pin the image to the exact version of @playwright/test in package-lock.json.
image: mcr.microsoft.com/playwright:v1.64.1-noble
options: --init --ipc=host --user 1001
defaults:
run:
working-directory: playwright
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 24
cache: npm
cache-dependency-path: playwright/package-lock.json
- run: npm ci
# No "playwright install": the browsers and their libraries are in the image.
- run: npx playwright test
- uses: actions/upload-artifact@v7
if: ${{ !cancelled() }}
with:
name: playwright-report
path: playwright/playwright-report
retention-days: 14
Source: Playwright: Via Containers
options are passed to docker create, which is where --init, --ipc=host and --user 1001 go. The documentation’s
own example uses --user 1001 and leaves the other two flags out; adding them follows the Docker page’s recommendation.
Running as pwuser means the checkout and the node_modules folder must be writable by that user, which the runner
arranges for the workspace. Under act the job passed with six tests, but act does not reproduce every container option
of the real runner, so the effect of --init and --ipc=host was checked with the plain docker run above.
Cypress
Cypress needs two commands in CI, install and cypress run, plus a way to start the application and wait for it. Cypress
maintains an official GitHub Action that does all of it. The Cypress documentation recommends binding to the latest
major version, cypress-io/github-action@v7 (release 7.4.7 on 23 October 2026), or to an exact release tag such as
7.4.7 if you want no surprises.
The Cypress GitHub Action
name: Cypress
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
jobs:
cypress:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v7
# Installs with "npm ci", restores and saves ~/.cache/Cypress and ~/.npm,
# starts the application, waits for it, runs "cypress run" and writes a job summary.
- uses: cypress-io/github-action@v7
with:
working-directory: cypress
start: npm --prefix ../app start
wait-on: 'http://localhost:3000'
wait-on-timeout: 60
browser: chrome
- uses: actions/upload-artifact@v7
if: failure()
with:
name: cypress-screenshots
path: cypress/cypress/screenshots
if-no-files-found: ignore
retention-days: 7
Source: cypress-io/github-action
In one step the action:
-
Installs dependencies with the command that matches the lock file:
npm ciforpackage-lock.json,pnpm install --frozen-lockfileforpnpm-lock.yaml,yarn --frozen-lockfileforyarn.lock(exactly one lock file must exist in the working directory). -
Caches the Cypress binary (in
~/.cache/Cypress, orCYPRESS_CACHE_FOLDER) and the package manager’s cache (~/.npmfor npm), with a key derived from the lock file. There is no built-in cache for pnpm and Yarn Modern; useactions/setup-nodefor those. -
Starts the application with
startin the background, and blocks untilwait-onanswers (wait-on-timeoutis in seconds). This replaces the race ofnpm start & npx cypress runthat the Cypress documentation warns against, and thestart-server-and-testorwait-onpackages. -
Runs
cypress runwith the requested browser, and publishes a job summary with the passed, failed, pending and skipped counts.
The run printed the usual Cypress banner and summary under act:
Browser: Chrome 156 (headless)
Specs: 1 found (smoke.cy.ts)
Running: smoke.cy.ts (1 of 1)
2 passing (325ms)
✔ All specs passed! 326ms 2 2 - -
Source: Cypress: GitHub Actions
The full list of action inputs (from the action’s action.yml) falls into a few groups:
| Input | Purpose |
|---|---|
|
Folder containing the Cypress project (and its lock file) in a monorepo; |
|
Skip or replace the install step; set |
|
Optional build, the server start command (run in the background), the URL to wait for and how long to wait. |
|
|
|
Run specific specs; override configuration values; use another config file; set Cypress environment variables ( |
|
Cypress Cloud recording and parallelization; see Cypress Cloud. |
|
Replace |
|
Silence Cypress output; turn the job summary off; name the summary when several action calls share a job. |
Outputs |
The Cypress Cloud URL of a recorded run ( |
The browser names refer to what is installed on the runner. GitHub’s Ubuntu images include Chrome, Firefox and Edge, but their versions change when the runner image is updated, which is a common source of "nothing in my repo changed" failures. Docker images, below, remove that variable.
Cypress Docker images
Cypress publishes four families of images (Docker Hub and the Amazon ECR Public Gallery; linux/amd64 and linux/arm64):
| Image | Contents |
|---|---|
|
Debian with the operating-system libraries Cypress needs, Node.js, npm and Yarn Classic. The Node version is selected by the tag. |
|
|
|
|
|
A Dockerfile generator for custom images with chosen versions of the operating system, Node.js, browsers and Cypress. |
Source: Cypress: Cypress Docker images
The short-form tag cypress/browsers:24.22.1 selects the newest browser versions built with that Node.js release; the
long-form tags such as node-24.22.1-chrome-156.0.8078.47-1-ff-158.0.1-edge-155.0.4283.62-1 pin every browser version.
Prefer a pinned tag over latest, so that a new major Node.js or browser release arrives as a reviewed change.
name: Cypress in a container
on:
workflow_dispatch:
permissions:
contents: read
jobs:
cypress:
runs-on: ubuntu-24.04
container:
# Node.js and the browsers are fixed by the image tag, not by the runner image.
image: cypress/browsers:24.22.1
options: --user 1001
steps:
- uses: actions/checkout@v7
- uses: cypress-io/github-action@v7
with:
working-directory: cypress
start: npm --prefix ../app start
wait-on: 'http://localhost:3000'
browser: firefox
options: --user 1001 avoids permission problems between the container user and the checked-out workspace, as the
action’s README advises. With cypress/included, set CYPRESS_INSTALL_BINARY: 0 in the step’s env, because the binary
is already in the image and a restored cache would otherwise conflict with it. Containers require a Linux runner.
Under act this workflow ran Firefox 158 in the image (2 passing), with the npm and Cypress caches saved by the
previous run restored (about 13 MB and 197 MB). The --user 1001 line had to be removed for act, because the cache step in the
action failed there with npm error path /.npm (the unprivileged user had no usable home directory in the act container,
which a real runner provides as /github/home).
Caching and install-time variables
Cypress downloads its binary once to a global cache: ~/.cache/Cypress on Linux, ~/Library/Caches/Cypress on macOS. In
CI that directory must persist between runs, or every run downloads a few hundred megabytes. The Cypress documentation
recommends caching ~/.cache (or the package manager’s own cache such as ~/.npm), not node_modules: the package
manager’s cache validates integrity and re-downloads only what changed, whereas a cached node_modules can leave npm
believing Cypress is installed when its binary is not. Use npm ci rather than npm install, keep cache keys strict (a
lax restore key lets old binaries accumulate), and use CYPRESS_CACHE_FOLDER if the default location must change.
Other variables of the install phase that are common in CI:
| Variable | Effect |
|---|---|
|
A version, URL or local file to install instead of the default; |
|
Download the binary from a mirror when the network blocks the public host. |
|
Where the binary is kept. |
|
Any configuration value can be set with the |
|
Proxy used to download the binary and to run tests; PAC files and SOCKS proxies are not supported. |
Source: Cypress: Advanced installation
CYPRESS_RECORD_KEY is different: it must be a real operating-system environment variable (from the provider’s secret
store), not a value in cypress.env.json or the configuration’s env block. The Cypress Cloud page covers recording,
parallelization and the --ci-build-id flag in Cypress Cloud, and the
configuration options in Getting Started.
Other providers
The Cypress documentation keeps worked examples for AWS Amplify, AWS CodeBuild, Azure Pipelines, Bitbucket Pipelines,
Buildkite, CircleCI, GitLab, Jenkins, Netlify, Semaphore and Travis CI. All of them reduce to the same recipe: cache the
global Cypress cache and the package manager cache, npm ci, boot the server with start-server-and-test, wait-on or
concurrently, and npx cypress run, optionally with --record --parallel on several machines. Machine size matters: if
videos have random pauses, the CPU percentage in the debug logs stays above 100% or the browser crashes, the runner has too
little CPU or memory; splitting very long specs into shorter ones also helps.
Selenium
Selenium does not ship a CI action; it is a library, so a pipeline simply runs the project’s own build (mvn verify,
pytest, dotnet test, npm test). The CI-specific decisions are where the browser and driver come from.
| Option | How | Trade-off |
|---|---|---|
Browser and driver on the runner |
GitHub’s Ubuntu images include Chrome, Firefox and Edge; Selenium Manager (bundled with the bindings) finds or downloads the matching driver. |
Simplest; browser versions change with the runner image, and Selenium Manager needs network access unless its cache is warm. |
Selenium Grid in a service container |
|
Pinned browser versions, a virtual display and noVNC built in; needs a way for the browser to reach the application. |
A Grid with several nodes |
|
Parallel sessions on many browsers; more moving parts. See Selenium Grid. |
A cloud grid |
A vendor’s Selenium endpoint; see Cross-Browser Testing. |
No infrastructure; needs an account, credentials and a tunnel to anything not public. |
Source: Selenium: Grid
Selenium Grid as a service container
The standalone image runs a complete Grid (router, distributor, node and a browser) in one container, which is ideal for a CI job. GitHub Actions starts it as a service container beside the job, waits for its health check, and publishes the port to the runner:
name: Selenium
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
jobs:
grid:
runs-on: ubuntu-24.04
services:
# The browser runs in this container; the tests run on the runner.
selenium:
image: selenium/standalone-chrome:4.51.0
ports:
- 4444:4444
options: >-
--shm-size=2g
--health-cmd "/opt/bin/check-grid.sh --host 0.0.0.0 --port 4444"
--health-interval 5s
--health-timeout 5s
--health-retries 12
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 24
- uses: actions/setup-python@v7
with:
python-version: '3.13'
cache: pip
cache-dependency-path: selenium-python/requirements.txt
- run: pip install -r selenium-python/requirements.txt
- name: Start the application
run: |
npm --prefix app start &
for i in $(seq 1 30); do
curl --silent --fail --output /dev/null http://localhost:3000/api/books && exit 0
sleep 1
done
exit 1
- name: Find an address of the runner that the Grid container can reach
run: echo "APP_HOST=$(hostname -I | awk '{print $1}')" >> "$GITHUB_ENV"
- name: Run the tests through the Grid
working-directory: selenium-python
run: python -m pytest tests
env:
SELENIUM_REMOTE_URL: http://localhost:4444
# The browser is in another container: give it an address that reaches the runner.
BASE_URL: http://${{ env.APP_HOST }}:3000
API_URL: http://localhost:3000
BOOKSHELF_READER_PASSWORD: ${{ secrets.BOOKSHELF_READER_PASSWORD }}
Source: docker-selenium
The pieces that are specific to a Grid:
-
--shm-size=2g. Chrome uses/dev/shm; Docker’s default of 64 MB makes it crash on larger pages. The same reason applies to--ipc=hostfor Playwright. -
The health check.
/opt/bin/check-grid.shis a script in the docker-selenium images that exits with success when the Grid reports itself ready. Without a health check the first test can start before a browser slot exists. The job log showed the container turn from "starting" to "healthy" before the steps ran. -
Two addresses for the application. The tests run on the runner and reach the application at
localhost:3000, but the browser runs inside the service container, wherelocalhostis the container itself. The workflow therefore passes the browser an address of the runner (BASE_URL, derived fromhostname -I) and keepsAPI_URLon localhost for the test-data reset call. The application must listen on all interfaces, not only on127.0.0.1. This worked underact; it was not tried on a GitHub-hosted runner, where the alternative is--add-host=host.docker.internal:host-gatewayin the serviceoptionsand aBASE_URLthat useshost.docker.internal. -
The test code. A fixture creates
webdriver.Remote(command_executor=GRID_URL, options=options)whenSELENIUM_REMOTE_URLis set, and a local headlesswebdriver.Chromeotherwise. The same switch in Java isnew RemoteWebDriver(new URL(gridUrl), options). See Selenium Grid for both bindings in full.
# selenium-python/tests/conftest.py
import os
import urllib.request
import pytest
from selenium import webdriver
# Where the browser opens the application, and (optionally) where the Grid is.
BASE_URL = os.environ.get("BASE_URL", "http://localhost:3000")
GRID_URL = os.environ.get("SELENIUM_REMOTE_URL")
@pytest.fixture(autouse=True)
def reset_data():
# Always reset through the address the test runner can reach.
api = os.environ.get("API_URL", BASE_URL)
urllib.request.urlopen(urllib.request.Request(f"{api}/api/test/reset", method="POST"))
@pytest.fixture
def driver():
options = webdriver.ChromeOptions()
if GRID_URL:
driver = webdriver.Remote(command_executor=GRID_URL, options=options)
else:
options.add_argument("--headless=new")
driver = webdriver.Chrome(options=options)
yield driver
driver.quit()
Source: Selenium: Drivers
Under act the service container was created, became healthy, and python -m pytest tests finished with
2 passed in 1.59s: the search test and a signed-in test, both driving the Chrome inside the container against the
Bookshelf on the job container. A first attempt, without the reader password in the environment, failed the sign-in test
with a timeout instead of an authentication error, because an unset secret expands to an empty string; see the secrets
section. For the Java binding, actions/setup-java with cache: maven plays the role of setup-python with
cache: pip, and mvn -B verify runs the tests; that variant was not run on this page.
For several parallel browsers, replace the standalone image with a hub and nodes started by docker compose up -d in an
earlier step (see Compose in Practice) and point the tests at the hub. A
Grid on a self-hosted fleet or in Kubernetes is covered in Selenium Grid.
Selenium Manager in CI
When a job runs the browser on the runner rather than in a Grid, Selenium Manager (a Rust command-line tool bundled in
every binding since Selenium 4.6) finds or downloads the driver, and since 4.11 can also download the browser. Its state
lives in ~/.cache/selenium, which makes it a good cache candidate, and it can be configured without code changes through
environment variables or a se-config.toml file in that cache. Every key has an environment variable: upper-case it,
replace - with _, prefix SE_.
| Variable | Use in CI |
|---|---|
|
Where drivers, browsers and metadata are stored; point it at a folder that the cache action saves. |
|
|
|
HTTP proxy used by Selenium Manager, for example |
|
Timeout for its network requests in seconds (default 300). |
|
Internal mirrors of the driver and browser repositories for air-gapped networks. |
|
Use a specific installed browser or request specific versions. |
|
Never download a browser; ignore drivers found on the |
|
|
|
Seconds that cached version lookups stay valid (default 3600). |
Source: Selenium: Selenium Manager
The order of precedence is CLI arguments (which the bindings fill from the browser options), then se-config.toml, then
environment variables. A proxy set in the browser options is used by the browser and by Selenium Manager; use SE_PROXY
when only the downloads need a proxy. Any other value (cache path, offline, mirrors) cannot be set from browser options,
so set it in the environment.
A job that uses the runner’s browser and caches Selenium Manager’s folder looks like this:
name: Selenium on the runner
on:
workflow_dispatch:
permissions:
contents: read
jobs:
local-chrome:
runs-on: ubuntu-24.04
env:
SE_CACHE_PATH: ${{ github.workspace }}/.selenium-cache
SE_AVOID_STATS: 'true'
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 24
- uses: actions/setup-python@v7
with:
python-version: '3.13'
cache: pip
cache-dependency-path: selenium-python/requirements.txt
- run: pip install -r selenium-python/requirements.txt
# chromedriver and the version metadata are downloaded once per week.
- uses: actions/cache@v6
with:
path: ${{ env.SE_CACHE_PATH }}
key: selenium-manager-${{ runner.os }}-${{ hashFiles('selenium-python/requirements.txt') }}
- run: npm --prefix app start &
- name: Run the tests with the Chrome of the runner
working-directory: selenium-python
run: python -m pytest tests
env:
BOOKSHELF_READER_PASSWORD: ${{ secrets.BOOKSHELF_READER_PASSWORD }}
This job was not run under act because the Linux arm64 container has no Chrome installed and no attempt was made to
add one; the Selenium Manager variables it relies on were exercised on the Mac instead. With an empty cache, no
network and Chrome already installed, the test run failed because the driver could not be found:
$ SE_CACHE_PATH=/tmp/se-cache SE_OFFLINE=true python -m pytest tests -x -q
WARNING selenium.webdriver.common.selenium_manager: Unable to discover proper chromedriver version in offline mode
E selenium.common.exceptions.NoSuchDriverException: Message: Unable to obtain driver for chrome; ...
Source: Selenium: Selenium Manager
The same command online filled the cache (chromedriver/mac-arm64/155.0.8059.84 and se-metadata.json, which holds the
resolved driver version, its time to live and the statistics entry) and passed:
$ SE_CACHE_PATH=/tmp/se-cache python -m pytest tests -q
.. [100%]
2 passed in 3.62s
$ SE_CACHE_PATH=/tmp/se-cache SE_OFFLINE=true python -m pytest tests -q
.. [100%]
2 passed in 1.28s
Source: Selenium: Selenium Manager
With the cache warm, SE_OFFLINE=true runs without any network access, which is the configuration for a locked-down
network after a first, cache-filling job. A proxy that cannot be reached fails at the first download, which is how a wrong
SE_PROXY shows up:
$ SE_CACHE_PATH=/tmp/se-cache2 SE_PROXY=http://127.0.0.1:9 SE_TIMEOUT=5 python -m pytest tests -x -q
E {'code': 65, 'message': 'error sending request for url (https://googlechromelabs.github.io/chrome-for-testing/known-good-versions-with-downloads.json)', 'driver_path': '', 'browser_path': ''}
Source: Selenium: Selenium Manager
A Grid in a container avoids all of this, because the driver and browser are already inside the image.
Running against a preview environment
Many teams deploy every pull request to a temporary URL (Vercel, Netlify, a Kubernetes namespace, a review app) and run the end-to-end suite against it. The suite then tests the artifact that will be deployed, behind the real network, TLS and proxy, rather than a server started on the runner. The workflow needs two things: the URL, and the commit that was deployed.
name: E2E against a preview
on:
# A deployment service reports "success" with the URL of the preview.
deployment_status:
# A person (or another workflow) can point the suite at any URL.
workflow_dispatch:
inputs:
base_url:
description: URL of the deployed application
required: true
permissions:
contents: read
jobs:
e2e:
if: github.event_name == 'workflow_dispatch' || github.event.deployment_status.state == 'success'
runs-on: ubuntu-24.04
container:
image: mcr.microsoft.com/playwright:v1.64.1-noble
options: --init --ipc=host --user 1001
env:
BASE_URL: ${{ github.event.inputs.base_url || github.event.deployment_status.target_url }}
defaults:
run:
working-directory: playwright
steps:
- uses: actions/checkout@v7
with:
# Test the commit that was deployed, not the tip of the default branch.
ref: ${{ github.event.deployment.sha || github.sha }}
- uses: actions/setup-node@v7
with:
node-version: 24
cache: npm
cache-dependency-path: playwright/package-lock.json
- run: npm ci
- name: Wait until the preview answers
run: |
for i in $(seq 1 30); do
curl --silent --fail --output /dev/null "$BASE_URL/api/books" && exit 0
sleep 2
done
echo "::error::$BASE_URL did not become ready"
exit 1
- run: npx playwright test --project=chromium
env:
BOOKSHELF_READER_PASSWORD: ${{ secrets.BOOKSHELF_READER_PASSWORD }}
Source: Playwright: On deployment
How the URL reaches each tool:
| Tool | Mechanism |
|---|---|
Playwright |
|
Cypress |
|
Selenium |
Your own variable ( |
Source: Cypress: Environment variables
Under act, a workflow_dispatch event with base_url set to the address of a second Bookshelf instance on the host
passed (Running 3 tests using 1 worker, 3 passed). The checkout step’s ref input had to be removed for that run,
because act cannot fetch a ref from GitHub for a local repository; the deployment_status trigger itself was not
exercised.
Preview testing has traps of its own:
-
The data. A shared preview has no per-test reset unless the application offers a test-only API, which should be off in production. Bookshelf has such an API (
/api/test/resetand/api/test/seed) and can disable it withBOOKSHELF_TEST_API=off; if the preview has it disabled, the fixtures that call it fail with a 404. Prefer tests that create their own data with unique names. -
Concurrency. Two runs against one preview share state. Give each pull request its own preview, or serialise runs with a
concurrencygroup keyed on the environment. -
Readiness. A deployment can report success before the application is warm; a readiness loop like the one above is cheaper than flaky first tests.
-
Authentication. Previews are often behind a protection layer (a bypass header or token); pass it as a secret through
extraHTTPHeaders(Playwright),cy.interceptor request headers (Cypress), or the browser options (Selenium).
Secrets
Test accounts, API tokens for staging back-ends, record keys and cloud-grid credentials all enter the job as GitHub secrets and are exposed to a step as environment variables:
- run: npx playwright test
env:
# The step sees the value; the log masks it; the code reads process.env.
BOOKSHELF_READER_PASSWORD: ${{ secrets.BOOKSHELF_READER_PASSWORD }}
Source: GitHub Actions: Using secrets
Rules that apply regardless of the tool:
-
Expose a secret to the smallest scope that needs it. Put
envon the step that runs the tests, not on the whole workflow, and never pass a secret to a step that runs code you did not review. -
Never print secrets. GitHub masks exact matches in logs, but not transformations of them (Base64, URL-encoded). Do not
echothem and do not enable debug logging of requests that contain them. -
Fork pull requests do not receive secrets. A fork’s
pull_requestworkflow runs with an empty value, which is why the first Selenium run above failed with a timeout instead of an authentication error: the sign-in form was submitted with an empty password. Make the test fail early and clearly when a required variable is empty. Do not switch topull_request_targetto get secrets; it runs the workflow definition of the base branch with secrets and, if it also checks out the pull request’s code, executes untrusted code with access to them. -
Artifacts are as sensitive as secrets. Playwright traces, HTML reports, Cypress screenshots and videos, and console logs can contain tokens, credentials of the test user and application data. The Playwright documentation advises uploading them only to trusted artifact stores or encrypting them. Use short retention (
retention-days). -
Use environments for production-like credentials. A GitHub environment can require reviewers before a job that uses its secrets runs, and can restrict which branches may use them.
-
Prefer short-lived credentials. OpenID Connect lets the workflow obtain a temporary token for a cloud provider instead of storing a long-lived key.
-
Record keys. The Cypress record key is not enough to read runs, but it can write to the project; keep it in a secret and pass it as
CYPRESS_RECORD_KEY.
Credentials in the example repository are the demonstration defaults of the Bookshelf application; real systems use values from the provider’s secret store only.
Artifacts, reports and annotations
Evidence is what turns "the build is red" into a diagnosis. Each tool leaves different files:
| Tool | Files worth keeping | How they are produced | How to read them |
|---|---|---|---|
Playwright |
|
The |
|
Cypress |
|
Screenshots on failure by default; video when |
Open the files, or use Cypress Cloud’s Test Replay when recording |
Selenium |
Screenshots, page source, browser logs, JUnit XML |
Your code in a failure hook; JUnit XML from the test runner |
Open the files; a test-report action renders the XML |
Source: Playwright: Reporters
Upload the evidence on failure with the right condition, and give each artifact a clear name:
- uses: actions/upload-artifact@v7
if: ${{ !cancelled() }} # runs after a failure, not after a manual cancel
with:
name: playwright-traces-${{ matrix.shardIndex }}
path: playwright/test-results
if-no-files-found: ignore
retention-days: 7
Source: actions/upload-artifact
Two artifact limits explain several surprises. Artifact names must be unique within a run in the current major versions of
the artifact actions, which is why the matrix index is in the name above; and artifacts are only available after the
uploading job completes, so one job cannot read another’s artifacts until that job has finished (needs).
Annotations and summaries
Artifacts live on the run page; what a pull request author sees first is the check status and the annotations on the diff.
-
Playwright’s
githubreporter prints workflow commands that GitHub turns into annotations on the failing line, plus a summary notice. A deliberately wrong assertion (toHaveCount(2)where the page shows one card) produced, withCI=1 npx playwright test --reporter=github,list:::error file=tests/fail.spec.ts,title=[chromium] › tests/fail.spec.ts:2:5 › search shows two results,line=6,col=47:: 1) [chromium] › ... ::notice title=🎭 Playwright Run Summary:: 1 failed%0A [chromium] › tests/fail.spec.ts:2:5 › search shows two results ... -
The Cypress action writes a job summary (Markdown on the run’s summary page) with the pass, fail, pending and skipped counts and the duration;
summary-titlenames it when a job calls the action more than once, andpublish-summary: falseswitches it off. Recorded runs addresultsUrl, the Cypress Cloud link, as a step output. -
JUnit XML is the lingua franca for everything else. Playwright has the
junitreporter, Cypress--reporter junit(a bundled reporter), JUnit and pytest produce it natively. Test-report actions and the provider’s test-result view then show failed tests, durations and history. To add a Markdown report yourself, append to the file in$GITHUB_STEP_SUMMARY. -
Workflow commands such as
::error file=...,line=...::messageand::notice::messagework from any step, so a Selenium suite can annotate a failure from aconftest.pyhook.
Cypress Cloud, Playwright Workspaces (Azure App Testing) and the cloud grids add dashboards, flaky-test history and pull request comments; they need accounts and tokens, so none of that was run for this page. See Cypress Cloud and Cross-Browser Testing.
Validating workflows with act
act runs a workflow’s jobs in Docker containers and emulates the GitHub environment (the runner file commands, the
github context, a local artifact and cache server). It is useful to catch YAML mistakes, wrong paths and missing
installation steps before pushing, and was how every workflow on this page was exercised. The commands used were of this
form, from the root of the example repository:
# once: download the release binary for the host platform, e.g. act_Darwin_arm64.tar.gz from
# https://github.com/nektos/act/releases and unpack it
act push -W .github/workflows/playwright.yml \
-P ubuntu-24.04=catthehacker/ubuntu:act-24.04 \
--container-architecture linux/arm64 \
--artifact-server-path ./.act-artifacts \
--secret-file .act.secrets # one NAME=value line per secret; keep it out of Git
act workflow_dispatch -W .github/workflows/preview.yml -e event.json # event.json: {"inputs":{"base_url":"..."}}
Source: nektos/act
-P maps the runs-on label to a Docker image; the catthehacker images are community-built approximations of GitHub’s
runner image and are much smaller than the real one. For Cypress the label was mapped to cypress/browsers:24.22.1
instead, because that image carries the system libraries and browsers Cypress needs, which the community image was not
built to provide; the real GitHub runner image includes them.
The differences between act and GitHub that affected the examples, and what was done for the runs:
-
Artifact actions.
act0.2.89’s artifact server rejectedactions/upload-artifact@v7(it does not understand the request’smime_typefield), so theactruns used copies of the workflows withupload-artifact@v4anddownload-artifact@v4. The published workflows use the current majors, v7 and v8, as listed by the actions' repositories on 25 October 2026. -
Parallel matrix jobs. The
actcontainers shared the host’s port 3000, so two shards could not both start the application; theactcopy of the Playwright workflow addedmax-parallel: 1. On GitHub each job has its own machine. -
Unprivileged container user. Removed
options: --user 1001from the Cypress container job (see above);--initand--ipc=hostwere validated withdocker runinstead. -
Checkout of a specific ref. Removed the
refinput from the preview workflow’s checkout. -
Architecture. The containers were linux/arm64, so the Chromium, Firefox and Chrome builds were the arm64 ones. Safari and WebKit-based results on a real runner are not covered.
A green act run therefore means that the commands, paths, caches and tool configuration work end to end in a clean Linux
container, not that GitHub’s scheduler, permissions, secrets handling or runner image will behave identically.
Pitfalls
-
No health check or wait. Starting the application and the tests in the same step races. Use
webServer.url, the Cypress action’swait-on, or a loop withcurl --fail. Do not use a fixedsleep. -
Browsers and tests out of sync. A Playwright image tag different from the
@playwright/testversion makes browser executables unfindable; a cached~/.cache/ms-playwrightkeyed without the version restores the wrong browsers after an upgrade. -
Caching the wrong things. Cache package-manager downloads and browser binaries, not
node_modules; keep the key tied to the lock file and tool version. -
localhostmeans a different machine. Inside a container or service container,localhostis the container. Use a reachable address for browsers in other containers, andhost.docker.internalonly where it resolves. -
Out-of-memory crashes. Chrome in a container with a small
/dev/shmcrashes with errors that look like random test failures. Use--ipc=hostor--shm-size. -
Hung jobs. Without
globalTimeout(Playwright) ortimeout-minutes, a stuck job runs until the provider’s limit, which defaults to hours, and Playwright produces no report if the runner kills it. -
Skipping the report when tests fail. An upload step without
if: ${{ !cancelled() }}(oralways()) does not run after a failing step, so the evidence disappears when it is needed. -
Unpinned actions and images. A floating
latesttag changes the browser or Node.js under you. Pin images, pin action majors (or full SHAs for security-sensitive workflows) and update them in a pull request. -
Secrets in forks. An empty secret looks like a wrong-password failure. Check for it at the start of the run.
-
Retries that hide problems. A suite that passes only on the second try is flaky; track flaky tests instead of raising the retry count. See Flaky Tests & Reliability.
-
Different results from local. Make the CI path reproducible on a laptop: the Docker run above, or the same
npm ciand test command, so a failure can be debugged without pushing commits.
What changed recently
-
GitHub actions, October 2026. The latest releases are
actions/checkoutv7.0.1,actions/setup-nodev7.1.0,actions/cachev6.1.0,actions/upload-artifactv7.0.2 andactions/download-artifactv8.0.2. The official Playwright and Cypress examples still showcheckout@v6,setup-node@v6andupload-artifact@v4orv5; older majors keep working, but check the release notes of an action before moving to a new major version. -
Playwright documentation, October 2026. The CI examples use the
v1.64.0-nobleimage, recommend settingglobalTimeoutinstead of a job-level timeout and one worker on CI, and the image tags include-resolutefor Ubuntu 26.04. -
Playwright 1.46.
--only-changed; 1.37 introduced theblobreporter andmerge-reportsthat the shard workflow uses. -
Cypress 16. Node.js 22, 24 or 26 and later are required (Node 20 and 25 are no longer supported), so a CI image or
setup-nodeversion must match; Cypress 16 removed the synchronous environment accessor, so test code reads secrets withcy.env(). -
cypress-io/github-action v7. The latest release is 7.4.7 (23 October 2026); the
exposeinput for public configuration values requires Cypress 15.10 or later; the README examples run onubuntu-26.04as well as 24.04. -
Selenium 4.51. The Docker Selenium images are published at
4.51.0and mirrored to the GitHub Container Registry; Selenium Manager is bundled with every binding and sends anonymous statistics unlessSE_AVOID_STATSis set.