E2E UI Tests for haxcms-php (DDEV-based)
August 7, 2026 · View on GitHub
Automated end-to-end tests that drive the real app-hax dashboard served by the
DDEV project (http://haxcms.ddev.site) through a headless browser
(puppeteer-core + system Chrome), validating that common user tasks still
succeed after code changes. This is the PHP-backend counterpart of the
haxcms-nodejs test:e2e suite — the browser, a11y, visual, and selector
helpers are shared verbatim; only the runtime harness differs (DDEV instead of
an in-process temp-dir server). See PHP-specific configuration
for the key differences from the NodeJS suite.
Tracks: haxtheweb/issues#2939
Prerequisites
- DDEV installed and Docker running. The harness runs
ddev start(idempotent) so the project just needs to be startable. The post-start hook (scripts/haxtheweb.sh admin admin) seeds theadmin/admincredentials the tests log in with. - Chrome/Chromium installed on the host. The harness auto-detects
/usr/bin/google-chrome(and common alternatives). Override withPUPPETEER_EXECUTABLE_PATH=/path/to/chromefor non-standard installs. - Node >= 18.20.3.
- devDependencies (
puppeteer-core,sharp,axe-core,pixelmatch,pngjs,fs-extra,axios) — runnpm install(oryarn) at the repo root.
The tests run on the host (puppeteer-core drives the host's Chrome against the DDEV-served URL); the DDEV container only needs to be running and seeded.
Running the tests
# from the haxcms-php repo root
npm run test:e2e
This runs all test/e2e/*.e2e.test.cjs files via Node's built-in test runner,
serialized (--test-concurrency=1) because the tests share a single DDEV
instance and a fixed site name (HAXSITEAUTOMATEDTESTING).
Individual test files
node --test test/e2e/login.e2e.test.cjs
node --test test/e2e/create-site.e2e.test.cjs
node --test test/e2e/archive-site.e2e.test.cjs
node --test test/e2e/edit-content.e2e.test.cjs
node --test test/e2e/export-site.e2e.test.cjs
node --test test/e2e/page-management.e2e.test.cjs
Updating visual baselines
Visual diffs warn but do not fail the test run. When a diff is intentional, regenerate baselines:
npm run test:e2e:update
Baselines are stored under test/e2e/__screenshots__/ as *.png files.
Runtime artifacts (*.current.png, *.diff.png) are written alongside for
manual inspection and are gitignored.
What the tests cover
| Test file | Task | Key assertions |
|---|---|---|
login.e2e.test.cjs | Login via two-step modal | POST /system/api/v1/session/login -> 200 + jwt + refresh cookie; dashboard renders; a11y scan of login form; visual baselines |
invalid-login.e2e.test.cjs | Bad credentials rejected | (skipped — see in-file note on two-step username validation); negative path documented |
create-site.e2e.test.cjs | Create HAXSITEAUTOMATEDTESTING | POST /system/api/v1/sites -> 200 + data.metadata.site.name + link; site exists on disk + in list API; a11y + visual baselines |
archive-site.e2e.test.cjs | Archive HAXSITEAUTOMATEDTESTING | POST /sites/:siteName/archive -> 200 + data.name + detail === 'Site archived'; site card removed; site dir moved to _archived/; a11y + visual baselines |
edit-content.e2e.test.cjs | Edit & save content in HAX editor | PATCH /x/api/v1/content/:idOrSlug -> 200; typed content present in the page HTML file on disk; a11y + visual baseline |
export-site.e2e.test.cjs | Export/download site as zip | POST /sites/:siteName/download -> 200 + data.link ends .zip + data.name; zip exists at _published/haxsiteautomatedtesting.zip with PK magic bytes; a11y + visual baselines |
page-management.e2e.test.cjs | Add + delete a page | POST /x/api/v1/items -> 200 + data.id + data.title; DELETE /x/api/v1/items/:id -> 200; page removed from site.json manifest + items list; a11y + visual baselines |
All site operations target the fixed name HAXSITEAUTOMATEDTESTING. The
harness removes any leftover site (_sites/, _archived/, _published/*.zip)
at the start of each run so the suite is idempotent.
PHP-specific configuration & base path differences
The app-hax dashboard/editor is served locally through the system's magic script (wc-registry.json dynamic hydration), not a remote CDN — CDN serving is opt-in and not in use here. Because the frontend is backend-agnostic, most of the NodeJS e2e helpers are reused verbatim. The differences below are PHP-backend-specific and were the root cause of the two site-editor-flow failures (edit-content, page-management) during initial porting.
/sites/ vs /_sites/ — the editor base path
This is the most important difference and the one most likely to bite when porting tests between backends.
- PHP dynamically selects
sitesDirectoryat runtime: if the/sitessymlink exists it picks'sites'(system/backend/php/lib/HAXCMS.php:166-167), otherwise'_sites'. The editor page's<base href>is therefore/sites/<name>/(emitted byHAXCMSSite::getBaseTag()→getPWABaseTagPath()). - NodeJS hardcodes
sitesDirectory = '_sites'(haxcms-nodejs/src/lib/HAXCMS.js:3043), so its editor base tag is/_sites/<name>/.
The site editor's client-side router resolves paths against the <base href>.
If a test navigates to /_sites/<name>/ on the PHP backend, the URL
mismatches the base tag and the router fails with AbortError: Transition was skipped — the editor shell renders but the active page never hydrates
(#editbutton stays disabled) and global event listeners never wire up.
The ported tests therefore use two distinct directory names:
SITE_URL_DIR = 'sites'for browser/API URLs (/{baseUrl}/sites/{name}/,/{baseUrl}/sites/{name}/x/api/v1/items) — matches the PHP base tag.SITES_DIR = '_sites'for filesystem paths (path.join(runtimeRoot, '_sites', name)) — matches the on-disk directory DDEV bind-mounts.
If the /sites symlink is absent (non-DDEV deploys where PHP falls back to
_sites), set SITE_URL_DIR = '_sites' in edit-content.e2e.test.cjs and
page-management.e2e.test.cjs, or remove the symlink so PHP and the tests agree.
Base URL is HTTP, not HTTPS
The harness coerces DDEV's HTTPS primary URL to http://haxcms.ddev.site
(helpers/harness.cjs resolveBaseUrl). Two reasons:
- The dev refresh cookie is non-Secure —
setRefreshTokenCookiedrives theSecureflag fromisProductionRuntime(), which is false withoutNODE_ENV=production(HAXCMS.php:2263-2273). So thehaxcms_refresh_tokencookie assertion works over HTTP. - HTTP avoids the cert path entirely. Even with DDEV's mkcert root CA
installed in the system + Chrome trust store, Chrome's HTTP/HTTPS
connection coalescing on the same DDEV host intermittently triggers
net::ERR_CERT_VERIFIER_CHANGEDon opportunistic HTTPS subresource loads under SPA load, blocking the dashboard.
No cert flags are passed to Chrome (--ignore-certificate-errors and
ignoreHTTPSErrors: true were both tried and both cause
ERR_CERT_VERIFIER_CHANGED by swapping Chrome's cert verifier mid-session).
--disable-dev-shm-usage is included to avoid /dev/shm pressure when
running 7 heavy SPA tests serially.
Site token for mutation endpoints
Site-scoped mutation endpoints (DELETE/PATCH /x/api/v1/items/*,
PATCH /x/api/v1/content/*) require an X-HAXCMS-Site-Token header, validated
per-site via HAXCMS::validateSiteToken → getSiteTokenForSiteName, which is
getRequestToken(user:sitename) — a per-user, per-site hash that cannot be
computed client-side.
page-management.e2e.test.cjs captures this token by listening to the page's
outgoing requests: the editor's own POST /x/api/v1/items (triggered by the
haxcms-create-node global event) carries the header, so the test intercepts
page.on('request') for /x/api/ and reuses the captured token for the direct
axios DELETE.
Editor global-event caveat (page delete)
The site editor listens for haxcms-create-node / haxcms-delete-node global
events and translates them into site API calls. On the PHP backend:
haxcms-create-nodeworks on the initial editor load (the listener is wired and the editor's API client mints the site token).haxcms-delete-nodedoes not reliably fire theDELETEcall — the handler needs the item present in the editor's local outline state, which isn't refreshed after the event-based create, and the listener does not re-wire after apage.reload()(the editor router doesn't fully re-initialize on PHP).
page-management therefore uses a direct axios DELETE (with the captured
site token) instead of the global event. The haxcms-create-node event still
verifies editor→API integration for the create path.
How it works
- DDEV harness (
helpers/harness.cjs) — runsddev start, resolves the base URL fromddev describe -j(coerced to HTTP; defaulthttp://haxcms.ddev.site), waits for the system API, smoke-loginsadmin/adminfor a ready JWT, and best-effort fetchesconnection-settingsfor theuserToken/userTokenHeader.runtime.runtimeRootis the haxcms-php repo root (DDEV bind-mounts it, so host-side filesystem assertions resolve to the same files the PHP backend operates on). Teardown leaves DDEV running for the user. - Browser driving (
helpers/browser.cjs) — launches headless Chrome viapuppeteer-core+ the system Chrome binary. No cert flags (see PHP-specific configuration);--disable-dev-shm-usagefor shm pressure under serial SPA load. Fixed 1280x800 viewport. Response collector captures every XHR/fetch to/system/api/v1/*and/x/api/. Arequestfailedlogger is always on; setHAXCMS_E2E_DEBUG_HTTP=1to log every 4xx/5xx response URL. - Accessibility (
helpers/axe.cjs) — injectsaxe-coreand runs WCAG 2.1 AA rules scoped to the task-relevant UI region. - Visual regression (
helpers/visual.cjs) — captures screenshots and diffs against committed baselines withpixelmatch/sharp. Diffs warn but never fail. - Selectors (
helpers/selectors.cjs) — centralised app-hax selector map (shadow-DOM chains) +deepQuery/deepQueryAll.
Known limitations
invalid-login.e2e.test.cjsis largely skipped — the two-step login validates the username locally before showing the password step, so the login API is never called with bad credentials. The positive login path is fully covered inlogin.e2e.test.cjs.- Full-suite (
npm run test:e2e) runs can be flaky under Chrome resource pressure (later tests' SPA loads slow down). Individual test files are reliable. Run a single file withnode --test test/e2e/<file>.cjsfor deterministic verification.
Environment overrides
HAXCMS_E2E_BASE_URL— override the base URL (defaulthttp://haxcms.ddev.site; the harness also coerces DDEV's HTTPS primary URL to HTTP).HAXCMS_E2E_USERNAME/HAXCMS_E2E_PASSWORD— override the login creds (defaultadmin/admin).PUPPETEER_EXECUTABLE_PATH— override the Chrome binary path.HAXCMS_E2E_UPDATE_SCREENSHOTS=1— regenerate visual baselines.HAXCMS_E2E_DEBUG_HTTP=1— log every 4xx/5xx response URL seen by the browser.
Debugging
Headed mode (watch the browser)
Set headless: false in the launchBrowser() call, or pass it from the test:
const browser = await launchBrowser({ headless: false })
Console output
The tests emit [e2e], [visual], [a11y], and [diag] prefixed warnings.
Pipe through grep to filter:
node --test test/e2e/create-site.e2e.test.cjs 2>&1 | grep '\[e2e\]'
File layout
test/e2e/
helpers/
harness.cjs # DDEV bootstrap (ddev start, HTTP base URL, smoke-login, cleanup)
browser.cjs # puppeteer launch + response collector (no cert flags; requestfailed + opt-in 4xx logger)
axe.cjs # axe-core inject + runA11y
visual.cjs # screenshot capture + baseline diff (WARN not fail)
selectors.cjs # centralised app-hax selector map (shadow-DOM chains)
index.cjs # re-exports all helpers
login.e2e.test.cjs
invalid-login.e2e.test.cjs
create-site.e2e.test.cjs
archive-site.e2e.test.cjs
edit-content.e2e.test.cjs
export-site.e2e.test.cjs
page-management.e2e.test.cjs
__screenshots__/ # committed baselines (*.png) + gitignored runtime artifacts
README.md # this file