Skip to content

Add runtime E2E fixture system (endpoint + Playwright client + FrankenPHP harness)#7

Merged
erikfrerejean merged 25 commits into
6from
e2e-fixture-system
Jul 7, 2026
Merged

Add runtime E2E fixture system (endpoint + Playwright client + FrankenPHP harness)#7
erikfrerejean merged 25 commits into
6from
e2e-fixture-system

Conversation

@erikfrerejean

@erikfrerejean erikfrerejean commented Jul 7, 2026

Copy link
Copy Markdown
Member

What & why

Adds a runtime YAML fixture system to this shared E2E support module so any SilverStripe module we E2E-test can seed and reset test data over HTTP — fixture loading/parsing is not a per-module concern. Clean-sheet implementation; no consuming module is referenced anywhere.

A dev-gated endpoint (/dev/e2e-fixtures) loads a named YAML fixture into the DB, applies versioned post-actions, returns the created record IDs as JSON for Playwright, and resets between tests. The loader is config-driven and domain-agnostic; consumers inject behaviour via extension hooks. Both HTTP and CLI entry points are provided.

Phase A — module code

  • WeDevelop\E2e\Fixtures\FixtureResult — immutable JSON DTO returned to Playwright.
  • FixturePostActionpublish_recursive / unpublish / modify / attach_image against any DataObject.
  • FixtureLoaderExtensible, config-driven (url_segment_prefix, fixture_page_classes, fixtures). Fires onBeforeLoad(string $name, FixtureFactory $factory) / onAfterLoad(FixtureResult $result) so consumers inject domain behaviour (e.g. suppressing model auto-scaffolding) with zero coupling in the engine.
  • FixtureController — dev-gated POST load / POST load-all / POST reset (JSON). Intentionally unauthenticated/CSRF-free, safe only because locked to dev by two independent layers (canInit() + init() 404) with an additional confirm=1 guard on the destructive reset.
  • _config/fixtures.yml — dev-only: registers the endpoint and relaxes strict_user_agent_check.
  • client/playwright/index.ts — reusable Playwright client (createFixtureClient with load/loadAll + authenticateAdmin), consumed via a tsconfig alias @wedevelop/e2evendor/wedevelopnl/silverstripe-e2e/client/playwright/index.ts.

Phase B — E2E harness + proof

  • Converts the CLI-only Docker harness to FrankenPHP serving (HTTPS, deterministic per-worktree ports) so the endpoint runs over real HTTP.
  • Host Playwright suite: tests/E2E/ with global.setup.ts (admin login) + a consumer-agnostic demo fixture.
  • tests/E2E/specs/fixture.spec.ts proves the whole chain in a real browser: admin login → load via the endpoint using the TS client → CMS navigation to the created page → title assertion.
  • Blocking CI e2e job (matrix chromium + firefox).

Serving over real HTTP caught a genuine latent bug the isolated PHPUnit tests structurally couldn't: the route config used the SS4/5 controller: key, but SS6 DevelopmentAdmin reads class: — fixed here.

Phase C — load-all + CLI

  • FixtureLoader::loadAll() + POST load-all: reset once, then seed every configured fixture into its own FixtureFactory (fail-fast on the first that raises). All fixture paths are resolved before the reset, so a misconfigured path cannot wipe E2E data or leave a partial seed. The Playwright client gains a matching loadAll.
  • LoadFixturesTask (sake load-e2e-fixtures): a thin CLI adapter over both loader modes — --fixture=<name> seeds one fixture, omitting it seeds every configured fixture. Dev-gated with the same posture as the controller (both modes reset(), which archives pages, so it refuses to run outside dev). Errors map cleanly: InvalidArgumentExceptionINVALID, RuntimeExceptionFAILURE. 100% line-covered, and exercised end-to-end through the real sake CLI.

Verification (all green locally)

task test 55/55 · task analyse + analyse-php85 clean · task rector-dry clean · task coverage-check 94.02% (≥90%) · npm run typecheck pass · task test-e2e 3/3 (real admin login + 2 chromium specs; unchanged by Phase C — re-run before merge).

Notes for review (deferred, non-blocking)

  • reset() scope: archives only fixture SiteTree pages (prefix + fixture_page_classes allowlist, refuses on empty allowlist). Both load and load-all reset once up front. Images created by attach_image and non-SiteTree fixture objects are not cleaned between loads — acceptable for a throwaway E2E DB, but worth knowing.
  • .gitattributes was added to export-ignore dev files (.docker/.github/coverage/tests/Taskfile) from the Packagist dist while keeping src//client//_config/.
  • The e2e CI job is validated with actionlint but only truly exercised by this PR's CI run.
  • A one-time DB grant (.docker/db-init/01-grant-tmpdb.sql) lets the app user create SapphireTest's ss_tmpdb_* databases (first DB-backed test in the suite).

SapphireTest creates and drops a throwaway ss_tmpdb_* database per test
class, but the mysql image only grants the app user privileges on the
single MYSQL_DATABASE. Add a first-init grant script so database-backed
tests can create their temp schema instead of failing with access denied.
Keep development-only tooling out of the Packagist tarball via
export-ignore: Docker setup, CI workflows, tests, coverage, the
Taskfile, and the git dotfiles. Runtime code (src, _config),
composer.json, docs, README and LICENSE remain in the release.
Add integration tests covering FixtureLoader's uncovered error paths (no
SiteTree created, missing path config, missing fixture file, unknown
post-action fixture) and its findPageInFactory fallback to a
non-allowlisted SiteTree subclass, plus FixturePostAction's missing image
source. Lifts total coverage from 82.33% to 92.11%.

The remaining uncovered lines are defensive guards unreachable without
mocking (record vanishing mid-write, ModuleResourceLoader returning null
for an already-guarded empty resource); no production code was changed.
FixtureController::load() only caught InvalidArgumentException from
FixtureLoader::load(), so structural fixture faults (RuntimeException,
e.g. "did not create any SiteTree records") escaped uncaught and
returned an HTML dev-error page instead of the JSON envelope the
Playwright client always expects. Catch RuntimeException too and
return a 500 JSON error envelope, preserving the existing 400 behavior
for InvalidArgumentException.
SilverStripe 6's DevelopmentAdmin::getRegisteredRoutes() reads each
controllers entry via $info['class']; the SS4/5-era 'controller' key
left it undefined and threw before any /dev/ route could resolve. This
only surfaced once the app was served over real HTTP (Task 7).
… and php-qa

Task 7 added ${WEB_PORT}/${DB_PORT} interpolation to compose.yml, but these two
pre-existing jobs ran $COMPOSE up without task docker-env, emitting 'variable is
not set' warnings (ports degraded to ephemeral bindings). Run task docker-env first,
matching the e2e job.
Thin BuildTask adapter over FixtureLoader exposing both load modes:
--fixture=<name> seeds a single fixture, omitting it seeds every
configured fixture (loadAll). Guarded to Director::isDev() because both
modes reset() first (archives pages), mirroring FixtureController's
route gating. InvalidArgumentException maps to INVALID, RuntimeException
to FAILURE.
The healthcheck touched a sentinel file before exec'ing frankenphp, so it
reported healthy on a dead server and could pass `up -d --wait` before :443
accepted connections — racing the first Playwright request in CI. Probe a real
TLS handshake to :443 instead, which only succeeds once FrankenPHP has bound the
socket and provisioned its internal cert. Sentinel removed from the entrypoint.

Published ports bound 0.0.0.0, exposing the unauthenticated /dev/e2e-fixtures
data-wipe endpoint and default-credential MySQL to the LAN on a dev host. Bind
both to 127.0.0.1, enforcing the controller's documented 'never reachable except
on a dev host' invariant at the network layer. Playwright and host clients still
reach the app via localhost.
@erikfrerejean erikfrerejean merged commit be04dbd into 6 Jul 7, 2026
6 checks passed
@erikfrerejean erikfrerejean deleted the e2e-fixture-system branch July 7, 2026 14:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant