Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
057bded
ci: grant app db user access to SapphireTest temp databases
erikfrerejean Jul 7, 2026
2257d01
test: add E2E fixture test-support scaffolding
erikfrerejean Jul 7, 2026
d4d7eb4
feat: add FixtureResult DTO
erikfrerejean Jul 7, 2026
5362dd9
feat: add FixturePostAction with all four actions
erikfrerejean Jul 7, 2026
5f187de
feat: add Extensible config-driven FixtureLoader
erikfrerejean Jul 7, 2026
637adb1
feat: add dev-gated FixtureController and config registration
erikfrerejean Jul 7, 2026
b8d726f
chore: add .gitattributes to exclude dev files from dist archive
erikfrerejean Jul 7, 2026
ce23632
Test fixture-engine error and edge branches to pass 90% coverage gate
erikfrerejean Jul 7, 2026
b2dbf64
fix: return JSON 500 when FixtureLoader raises a structural fault
erikfrerejean Jul 7, 2026
e7bbbe1
feat: add Playwright fixture client and auth helper
erikfrerejean Jul 7, 2026
f4eee7e
build: serve the test app via FrankenPHP for E2E
erikfrerejean Jul 7, 2026
06de325
fix: register e2e-fixtures under DevelopmentAdmin 'class' key
erikfrerejean Jul 7, 2026
ca2befa
refactor: nest the Playwright client under client/playwright/
erikfrerejean Jul 7, 2026
86eb682
build: restore git and unzip in the FrankenPHP image for composer res…
erikfrerejean Jul 7, 2026
201a2c9
test: add Playwright harness and demo fixture
erikfrerejean Jul 7, 2026
899d49e
test: prove the fixture system end-to-end in a browser
erikfrerejean Jul 7, 2026
0faf18d
ci: add blocking E2E job (chromium, firefox)
erikfrerejean Jul 7, 2026
6a1fd22
docs: document the E2E fixture system
erikfrerejean Jul 7, 2026
48958a3
ci: generate deterministic ports before compose up in static-analysis…
erikfrerejean Jul 7, 2026
3bf2954
feat: add FixtureLoader::loadAll to seed every fixture in one call
erikfrerejean Jul 7, 2026
fffe9a2
feat: add POST load-all endpoint to FixtureController
erikfrerejean Jul 7, 2026
e3f9b2a
feat: add loadAll to the Playwright fixture client
erikfrerejean Jul 7, 2026
6c037be
harden: resolve loadAll fixture paths before reset so a bad path cann…
erikfrerejean Jul 7, 2026
4bb547e
feat: add load-e2e-fixtures CLI task
erikfrerejean Jul 7, 2026
be48395
harden: probe real readiness and bind published ports to loopback
erikfrerejean Jul 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .docker/Caddyfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
localhost {
root * /app/public
php_server
}
37 changes: 21 additions & 16 deletions .docker/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,25 +1,29 @@
ARG PHP_VERSION=8.3
FROM php:${PHP_VERSION}-cli
FROM dunglas/frankenphp:php${PHP_VERSION}

# git + unzip: the official php:*-cli image ships neither. Composer needs git to fall
# back to a source clone when a dist (zipball) download fails transiently — without it a
# single failed download aborts the whole install ("Source fallback is disabled. Not
# trying alternative sources."). unzip gives faster, more reliable archive extraction
# than composer's PHP-zip fallback.
# git + unzip: the frankenphp base ships neither. Composer needs git to fall back to a
# source clone when a dist (zipball) download fails transiently — without it a single
# failed download aborts the whole install ("Source fallback is disabled."). unzip gives
# faster, more reliable archive extraction than composer's PHP-zip fallback.
RUN apt-get update \
&& apt-get install -y --no-install-recommends git unzip \
&& rm -rf /var/lib/apt/lists/*

# mlocati's installer builds the needed extensions across all target PHP versions.
COPY --from=mlocati/php-extension-installer /usr/bin/install-php-extensions /usr/local/bin/
RUN install-php-extensions intl gd mysqli pdo_mysql zip bcmath exif pcov
RUN install-php-extensions \
intl \
gd \
mysqli \
pdo_mysql \
zip \
bcmath \
exif \
pcov

# Measure coverage against the mounted module source only.
RUN echo 'pcov.directory=/module/src' > /usr/local/etc/php/conf.d/99-pcov.ini

# The TinyMCE build task loads a translation catalogue per i18n locale (hundreds),
# so PHP's 128M default is insufficient; raise it container-wide so every invocation
# (task, CI running vendor/bin/phpunit directly, and manual runs) is covered.
# so PHP's 128M default is insufficient; raise it container-wide.
RUN echo 'memory_limit=512M' > /usr/local/etc/php/conf.d/99-memory.ini

COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
Expand All @@ -28,11 +32,11 @@ WORKDIR /app

COPY app/composer.json /app/composer.json

# SilverStripe requires Page and PageController classes in the project. These are
# generated here (not committed) to avoid duplicate class detection when the module
# is symlinked into vendor/. SilverStripe discovers modules by looking for _config/;
# without a project-root _config/, /app/src/ is not scanned and Page/PageController
# stay invisible to the class manifest (which breaks temp-database creation in tests).
# SilverStripe requires Page and PageController classes in the project. Generated
# here (not committed) to avoid duplicate class detection when the module is
# symlinked into vendor/. Without a project-root _config/, /app/src/ is not scanned
# and Page/PageController stay invisible to the class manifest (which breaks
# temp-database creation in tests).
RUN mkdir -p /app/src /app/_config \
&& printf '<?php\n\nuse SilverStripe\\CMS\\Model\\SiteTree;\n\nclass Page extends SiteTree\n{\n}\n' > /app/src/Page.php \
&& printf '<?php\n\nuse SilverStripe\\CMS\\Controllers\\ContentController;\n\nclass PageController extends ContentController\n{\n}\n' > /app/src/PageController.php
Expand All @@ -43,6 +47,7 @@ COPY app/phpstan.neon.dist /app/phpstan.neon.dist
COPY app/phpstan-php85.neon.dist /app/phpstan-php85.neon.dist
COPY app/rector.php /app/rector.php

COPY Caddyfile /etc/caddy/Caddyfile
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh

Expand Down
10 changes: 10 additions & 0 deletions .docker/app/_config/e2e.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
Name: app-e2e-fixtures
Only:
environment: dev
---
WeDevelop\E2e\Fixtures\FixtureLoader:
fixture_page_classes:
- Page
fixtures:
demo-home: 'wedevelopnl/silverstripe-e2e:tests/E2E/Fixture/demo.yml'
27 changes: 26 additions & 1 deletion .docker/compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ services:
dockerfile: Dockerfile
args:
PHP_VERSION: ${PHP_VERSION:-8.3}
ports:
# Loopback-only: the /dev/e2e-fixtures endpoints are unauthenticated and, on a
# dev host, are a remote data-wipe primitive. Binding to 127.0.0.1 enforces the
# controller's documented "never reachable except on a dev host" invariant at the
# network layer. Playwright and the host both reach the app via localhost.
- "127.0.0.1:${WEB_PORT}:443"
volumes:
- ../composer.json:/module/composer.json:ro
- ../src:/module/src
Expand All @@ -13,7 +19,18 @@ services:
- ../coverage:/app/coverage
- vendor:/app/vendor
healthcheck:
test: test -f /tmp/.app-ready
# Probe the real server, not a sentinel file. The entrypoint's dev/build finishing
# says nothing about whether FrankenPHP has bound :443 and provisioned its internal
# TLS cert — a sentinel touched before `frankenphp run` reports healthy on a dead
# server and can pass `--wait` before the socket accepts connections, racing the
# first Playwright request. A TLS handshake to :443 proves the server is actually
# serving. `php` is guaranteed present (it is the image); verify_peer is off because
# the cert is FrankenPHP's self-signed internal CA. The `$$` escapes Compose's own
# variable interpolation so a literal `$e`/`$s` reaches PHP.
test: >-
php -r 'exit(@stream_socket_client("tls://localhost:443", $$e, $$s, 2,
STREAM_CLIENT_CONNECT, stream_context_create(["ssl" => ["verify_peer" => false,
"verify_peer_name" => false]])) ? 0 : 1);'
interval: 3s
start_period: 60s
retries: 20
Expand All @@ -32,8 +49,16 @@ services:

db:
image: mysql:8
ports:
# Loopback-only: MySQL uses default credentials and must not be reachable from
# the LAN. The app container reaches the DB over the Docker network (SS_DATABASE_SERVER=db),
# so this published port exists only for host-side clients on the dev machine.
- "127.0.0.1:${DB_PORT}:3306"
volumes:
- db-data:/var/lib/mysql
# Grants the app user access to SapphireTest's ss_tmpdb_* throwaway databases
# (added in Task 1; runs on first init of a fresh data volume). MUST be kept.
- ./db-init:/docker-entrypoint-initdb.d:ro
environment:
MYSQL_DATABASE: silverstripe
MYSQL_USER: silverstripe
Expand Down
9 changes: 9 additions & 0 deletions .docker/db-init/01-grant-tmpdb.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
-- SapphireTest runs each test class against a throwaway database named
-- `ss_tmpdb_<random>`, which it CREATEs and DROPs itself. The mysql image only
-- grants the application user privileges on the single MYSQL_DATABASE, so without
-- this grant every database-backed test fails with "Access denied ... to database
-- 'ss_tmpdb_...'". The `\_` escapes the underscore to a literal so the pattern
-- matches the real temp-db names; `%` stays a wildcard for the random suffix.
-- Runs only on first initialisation of a fresh data volume (CI and clean checkouts).
GRANT ALL PRIVILEGES ON `ss\_tmpdb%`.* TO 'silverstripe'@'%';
FLUSH PRIVILEGES;
18 changes: 12 additions & 6 deletions .docker/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,17 @@ set -e

composer install --no-interaction

# Build the schema + class/config manifest so silverstan and sapphire tests have
# a ready environment. DB is guaranteed up (compose depends_on: db healthy).
vendor/bin/sake dev/build flush=1
# FrankenPHP ships a default phpinfo() index.php. Replace it with the SilverStripe
# bootstrap once composer install has made the recipe available.
cp -f vendor/silverstripe/recipe-core/public/index.php /app/public/index.php

# Ensure all vendor package resources are exposed. composer install skips the
# vendor-expose step when the named Docker volume already has packages from a
# previous run (no post-install event fires).
composer vendor-expose

touch /tmp/.app-ready
vendor/bin/sake dev/build flush=1

# No webserver: block so `docker compose exec` can run tests/analysis in this container.
exec tail -f /dev/null
# Readiness is probed by the compose healthcheck via a real TLS handshake to :443
# (see .docker/compose.yml) — no sentinel file, so a crashed server can't read healthy.
exec frankenphp run --config /etc/caddy/Caddyfile
20 changes: 20 additions & 0 deletions .docker/env.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
#!/bin/sh
set -e

SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
WORKTREE_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
DIR_NAME="$(basename "$WORKTREE_DIR")"

HASH=$(printf '%s' "$DIR_NAME" | cksum | awk '{print $1}')
OFFSET=$((HASH % 1000))

WEB_PORT=$((8000 + OFFSET))
DB_PORT=$((13000 + OFFSET))

cat > "$SCRIPT_DIR/.env" <<EOF
COMPOSE_PROJECT_NAME=${DIR_NAME}
WEB_PORT=${WEB_PORT}
DB_PORT=${DB_PORT}
EOF

echo "Generated .docker/.env: WEB_PORT=${WEB_PORT} DB_PORT=${DB_PORT}"
12 changes: 12 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Exclude development-only files from the Packagist/Composer dist archive.
# `export-ignore` keeps these out of `git archive` (i.e. the tarball Packagist
# publishes), while leaving them in the repository for local development and CI.

/.docker export-ignore
/.github export-ignore
/coverage export-ignore
/tests export-ignore

/.gitattributes export-ignore
/.gitignore export-ignore
/Taskfile.yml export-ignore
76 changes: 76 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ jobs:
version: 3.x
repo-token: ${{ secrets.GITHUB_TOKEN }}
- uses: ./.github/actions/setup-docker-mirror
- run: task docker-env
- name: Build image (GHA layer cache)
run: $COMPOSE build app
- name: Start services
Expand Down Expand Up @@ -77,6 +78,7 @@ jobs:
version: 3.x
repo-token: ${{ secrets.GITHUB_TOKEN }}
- uses: ./.github/actions/setup-docker-mirror
- run: task docker-env
- name: Build image (GHA layer cache)
run: $COMPOSE build app
- name: Start services
Expand Down Expand Up @@ -105,3 +107,77 @@ jobs:
name: PHPUnit Results (PHP ${{ matrix.php }})
path: coverage/junit.xml
reporter: java-junit

e2e:
name: E2E (${{ matrix.browser }})
runs-on: ubuntu-latest
permissions:
contents: read
checks: write
strategy:
fail-fast: false
matrix:
browser: [chromium, firefox]
env:
COMPOSE: docker compose -f .docker/compose.yml -f .docker/compose.ci.yml
COMPOSE_BAKE: 'true'
CACHE_SCOPE: app-php8.3
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
steps:
- uses: actions/checkout@v7
- name: Log in to Docker Hub
if: env.DOCKERHUB_USERNAME != ''
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- uses: arduino/setup-task@v3
with:
version: 3.x
repo-token: ${{ secrets.GITHUB_TOKEN }}
- uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: npm
- run: npm ci
- uses: actions/cache/restore@v4
id: playwright-cache
with:
path: ~/.cache/ms-playwright
key: playwright-${{ matrix.browser }}-${{ hashFiles('package-lock.json') }}
- if: steps.playwright-cache.outputs.cache-hit == 'true'
run: npx playwright install ${{ matrix.browser }}
- if: steps.playwright-cache.outputs.cache-hit != 'true'
run: npx playwright install --with-deps ${{ matrix.browser }}
- if: steps.playwright-cache.outputs.cache-hit != 'true'
uses: actions/cache/save@v4
with:
path: ~/.cache/ms-playwright
key: playwright-${{ matrix.browser }}-${{ hashFiles('package-lock.json') }}
- uses: ./.github/actions/setup-docker-mirror
- run: task docker-env
- name: Build image (GHA layer cache)
run: $COMPOSE build app
- name: Start services
run: |
$COMPOSE up -d --wait app || {
echo "::group::app container logs"; $COMPOSE logs app; echo "::endgroup::"
$COMPOSE ps -a
exit 1
}
- name: Run Playwright
run: npx playwright test --project=${{ matrix.browser }} --reporter=html,junit
env:
PLAYWRIGHT_JUNIT_OUTPUT_NAME: reports/playwright-junit.xml
- uses: dorny/test-reporter@v3
if: always()
with:
name: Playwright Results (${{ matrix.browser }})
path: reports/playwright-junit.xml
reporter: java-junit
- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report-${{ matrix.browser }}
path: playwright-report/
retention-days: 7
10 changes: 10 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@
composer.lock
vendor/

# Docker
.docker/.env

# PHPUnit / coverage
.phpunit.cache/
coverage/

# Node / Playwright
node_modules/
/test-results/
/playwright-report/
/playwright/.cache/
tests/E2E/.auth/
1 change: 1 addition & 0 deletions .nvmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
26.4.0
Loading
Loading