A DDEV add-on for visual regression testing of any Drupal site using
Playwright. Define which pages to screenshot in a
YAML config file, capture baselines on main, and compare against your feature
branch. Supports multiple viewports, LTR + RTL, authenticated and anonymous
sessions, and a small DSL for interactive states (form fills, modal opens,
etc.).
# Install
ddev add-on install https://github.com/mherchel/ddev-drupal-vrt/tarball/main
ddev restart
ddev exec -d /var/www/html/.ddev/drupal-vrt npm install
ddev exec -d /var/www/html/.ddev/drupal-vrt npx playwright install --with-deps chromium
# Capture baselines on main, then test your feature branch
git checkout main
ddev vrt-update
git checkout my-feature
ddev vrtConfiguration lives at .ddev/drupal-vrt.yaml (created automatically from
sensible defaults on first install). Edit it, commit it.
| Command | Description |
|---|---|
ddev vrt |
Run VRT against baselines. Prompts for a mode unless --<mode> or --project=<name> is passed. |
ddev vrt-update |
Capture or update baseline screenshots. |
ddev vrt-report |
Serve the HTML diff report at https://<project>.ddev.site:9324. |
Common flags for ddev vrt:
--<mode>— run a mode defined indrupal-vrt.yaml(e.g.--normal,--full).--project=<viewport>-<direction>— single project, e.g.--project=narrow-ltr.--bail=N— stop after N failures (default comes frombail:in yaml).--no-bail— run every test regardless of failures.--debug— open the Playwright inspector.- Any other flag passes straight through to
playwright test.
Everything is driven by .ddev/drupal-vrt.yaml. The default looks roughly like:
version: 1
defaults:
auth: admin # admin | anonymous | <named-role>
viewports: [narrow, mid, wide]
directions: [ltr] # add rtl to capture RTL too
fullPage: true
timeout: 5000
modes:
normal:
viewports: [narrow, wide]
directions: [ltr]
full:
viewports: [narrow, mid, wide]
directions: [ltr, rtl]
default: normal
bail: 5
workers: 2 # parallel test runners — lower for heavy sites/containers
pages:
- id: front
path: /
auth: anonymous
- id: content-overview
path: /admin/content
- id: node-add-article
path: /node/add/article
interactions:
- label: filled
steps:
- fill: { selector: '#edit-title-0-value', value: 'Test article' }
- fill: { selector: '#edit-body-0-value', value: 'Body copy' }For the full schema with every option and DSL primitive documented inline, see
drupal-vrt/defaults/drupal-vrt.example.yaml.
Three layers cooperate, applied in this order before each screenshot:
drupal-vrt/fixtures/hide-dynamic.css— bundled with the add-on, covers the common Drupal cases (timestamps, tokens, toolbar clocks). Overwritten on every add-on update..ddev/drupal-vrt.css— project-owned. Created on first install from a starter template; commit it. Use for site-specific dynamic content (uptime widgets, "last updated" timestamps, news rotators).defaults.cssand per-pagecssindrupal-vrt.yaml— inline CSS strings for global or page-specific tweaks.
Each layer's rules cascade on top of the previous, so per-page rules win over project rules win over bundled defaults.
Each page declares an auth: value:
admin(default) — logs in as uid 1 viadrush uli. Override for CI by settingDRUPAL_ADMIN_USER/DRUPAL_ADMIN_PASS, or by adding ausers.admin: { username, password }block.anonymous— no login.<role-name>— logs in via the standard form using credentials inusers.<role-name>. Use${ENV_VAR}refs for passwords:
users:
editor:
username: editor
password: ${VRT_EDITOR_PASS}Roles not referenced by any page are skipped — you only need creds for the roles you actually test.
Modes are named presets of viewport × direction combinations. The default
config ships with normal (narrow + wide, LTR) for fast iteration and full
(all viewports, LTR + RTL) for thorough runs. Add your own:
modes:
smoke:
viewports: [wide]
directions: [ltr]
default: smokeThen ddev vrt --smoke runs that profile.
Anything in defaults: can be overridden inline on a page:
pages:
- id: people-permissions
path: /admin/people/permissions
timeout: 30000 # large page needs more stability time
testTimeout: 90000 # and more total time
fullPage: false
- id: code-editor
path: /admin/some/editor
directions: [ltr] # skip RTL for this page
- id: optional-feature
path: /admin/maybe-not-installed
skipIfStatus: [403, 404] # auto-skip when route is unavailableTo screenshot a state that requires interaction (modal open, filled form),
declare an interactions: list. Each interaction generates an additional
screenshot named <id>--<label>.png. The supported step primitives:
| Step | Form | Notes |
|---|---|---|
click |
'<sel>' or { selector, button?, count? } |
|
fill |
{ selector, value } |
|
press |
{ selector?, key } |
Without selector, presses on the page |
hover |
'<sel>' |
|
select |
{ selector, value } |
<select> elements |
check / uncheck |
'<sel>' |
Checkboxes/radios |
waitFor |
'<sel>' or { selector, state? } |
states: visible / hidden / attached / detached |
scroll |
{ selector } or { x, y } |
Anything more complex than the DSL covers — open an issue.
- On the first invocation per session, an
auth-setupproject logs in for each role referenced by any page and stashes session state in.auth/<role>.json. - Each test navigates to the configured
path, applies optional CSS overrides, optionally injectsdir="rtl"for RTL projects, runs interaction steps if any, and callstoHaveScreenshot()to compare. - A bundled stylesheet (
fixtures/hide-dynamic.css) hides timestamps, CSRF tokens, and other content that changes between runs.
.ddev/
├── commands/web/
│ ├── vrt
│ ├── vrt-update
│ └── vrt-report
├── docker-compose.vrt-report.yaml
├── drupal-vrt.yaml ← user config (commit this)
├── drupal-vrt.css ← user CSS for hiding dynamic content (commit this)
└── drupal-vrt/
├── playwright.config.ts
├── fixtures/
│ ├── auth.setup.ts
│ └── hide-dynamic.css
├── src/
│ ├── config/ ← yaml loader
│ └── dsl/ ← step interpreter
├── tests/vrt/
│ ├── all.spec.ts ← single generated spec
│ └── generate-vrt-tests.ts
└── defaults/
├── drupal-vrt.yaml ← shipped default config
└── drupal-vrt.example.yaml ← schema + DSL reference
Baselines and test output land in the project root:
project-root/
├── __screenshots__/ # baseline PNGs
└── test-results/ # diff output (gitignored)
"drush uli was not found" or login fails — Drupal isn't installed yet. Run
ddev drush site:install first.
Tests fail on first run — capture baselines first with ddev vrt-update.
Flaky pages with dynamic content — add the unstable selector to
maskSelectors: (per-page or in defaults:), or to the bundled
fixtures/hide-dynamic.css. Or bump timeout: on the page if the issue is
stability rather than dynamic data.
Flaky timing under parallel load — heavy themes or resource-starved
containers can cause intermittent timeouts when multiple workers hit the site
at once. Lower workers: in drupal-vrt.yaml (default 2). Setting it to 1
serializes the run.
Tests passing on changes I expected to catch — adjust the diff thresholds.
A test fails only when more than maxDiffPixelRatio of pixels differ from
baseline (default 1%), where "differ" means color delta exceeds threshold
(default 0.2). To tighten globally, set in defaults::
defaults:
maxDiffPixelRatio: 0.005 # fail at 0.5% of pixels different
threshold: 0.15 # tighter per-pixel sensitivityOr override per-page when one is unusually noisy:
pages:
- id: hero-heavy-landing
path: /landing
threshold: 0.4drupal-vrt.yaml not found — copy from defaults:
cp .ddev/drupal-vrt/defaults/drupal-vrt.yaml .ddev/drupal-vrt.yaml.
Port 9324 not accessible for the report — ddev restart to load the
docker-compose port mapping.
Set form-login credentials in env (skips the drush uli path):
DRUPAL_ADMIN_USER=admin
DRUPAL_ADMIN_PASS=adminBASE_URL overrides the default https://localhost if the site lives
elsewhere in CI.