diff --git a/.circleci/config.yml b/.circleci/config.yml
index 23053e2d45..6973d6f0ce 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -1,72 +1,67 @@
version: 2.1
-jobs:
- build:
- machine:
- image: ubuntu-2204:current
- resource_class: large
- environment:
- - TZ: "UTC"
-
+commands:
+ install-task:
steps:
- - checkout
-
- # Install Task
- run:
name: Install Task
command: |
sudo sh -c "$(curl --location https://taskfile.dev/install.sh)" -- -d -b /usr/local/bin
task --version
- # Set up environment file
+ setup-env:
+ steps:
- run:
name: Setup environment
command: |
cp .env.example .env
-
+
# Configure for Docker Compose setup
sed -i 's/DB_HOST=.*$/DB_HOST=restarters_db/g' .env
sed -i 's/DB_DATABASE=.*$/DB_DATABASE=restarters_db_test/g' .env
sed -i 's/DB_USERNAME=.*$/DB_USERNAME=restarters/g' .env
sed -i 's/DB_PASSWORD=.*$/DB_PASSWORD=s3cr3t/g' .env
-
+
# Configure Discourse integration
sed -i 's/FEATURE__DISCOURSE_INTEGRATION=.*$/FEATURE__DISCOURSE_INTEGRATION=true/g' .env
sed -i 's/DISCOURSE_URL=.*$/DISCOURSE_URL=http:\/\/restarters_discourse/g' .env
sed -i 's/DISCOURSE_APIKEY=.*$/DISCOURSE_APIKEY=fb71f38ca2b8b7cd6a041e57fd8202c9937088f0ecae7db40722bd758dda92fc/g' .env
sed -i 's/DISCOURSE_APIUSER=.*$/DISCOURSE_APIUSER=someuser/g' .env
-
+
# Configure for testing
sed -i 's/APP_DEBUG=.*$/APP_DEBUG=false/g' .env
sed -i 's/SESSION_DOMAIN=.*$/SESSION_DOMAIN=localhost/g' .env
sed -i 's/HONEYPOT_DISABLE=.*$/HONEYPOT_DISABLE=TRUE/g' .env
sed -i 's/APP_URL=.*$/APP_URL=http:\/\/localhost:8001/g' .env
-
- # Add environment variables from CircleCI
+
+ # The Nuxt client origin (SSO bridge redirects, session config).
echo "" >> .env
+ echo "FRONTEND_URL=http://localhost:3000" >> .env
+
+ # The mysql_testing connection (used by the Taskfile's explicit
+ # pre-phpunit migrate:fresh). phpunit.xml sets these inside the
+ # phpunit process, but plain artisan reads .env — without them
+ # DB_TEST_HOST falls back to 127.0.0.1 and the reset fails.
+ echo "DB_TEST_HOST=restarters_db" >> .env
+ echo "DB_TEST_PORT=3306" >> .env
+ echo "DB_TEST_DATABASE=restarters_db_test" >> .env
+ echo "DB_TEST_USERNAME=restarters" >> .env
+ echo "DB_TEST_PASSWORD=s3cr3t" >> .env
+
+ # Add environment variables from CircleCI
echo "GOOGLE_API_CONSOLE_KEY=$GOOGLE_API_CONSOLE_KEY" >> .env
echo "MAPBOX_TOKEN=$MAPBOX_TOKEN" >> .env
- # Start Docker services using Task
- - run:
- name: Start Docker services
- command: |
- # Set environment variable for CircleCI detection
- export CIRCLECI=true
- # Enable Docker Compose bake for build optimization
- export COMPOSE_BAKE=true
- # Start all services using Task
- task docker:up-all
- no_output_timeout: 10m
-
- # Wait for core services to be ready (MySQL + web app). Discourse is started
- # via docker:up-all but its readiness is checked separately (non-blocking).
- # docker_run.sh runs npm install + npx playwright install + migrate:fresh --seed
- # on every startup, which can take 20-25 min on cold CI machines. We poll
- # directly here with a 25-minute ceiling rather than using the Taskfile helper
- # (which only allows 10 min) to avoid blocking on that limit.
+ wait-for-core:
+ parameters:
+ probe:
+ type: string
+ # Blade homepage by default; jobs with SKIP_NPM_INSTALL must probe an
+ # API route instead (no Vite manifest -> Blade pages 500).
+ default: "http://localhost:8001"
+ steps:
- run:
- name: Wait for services
+ name: Wait for core services
command: |
# Wait for MySQL first
echo "Waiting for MySQL..."
@@ -89,7 +84,7 @@ jobs:
docker logs restarters --tail=100 2>&1 || true
exit 1
fi
- curl -f -s http://localhost:8001 >/dev/null 2>&1 && echo "✓ Web app ready after $((i*5))s" && break
+ curl -f -s << parameters.probe >> >/dev/null 2>&1 && echo "✓ Web app ready after $((i*5))s" && break
if [ $i -eq 420 ]; then
echo "❌ Web app not ready after 35 minutes — container logs:"
docker logs restarters --tail=100 2>&1 || true
@@ -100,6 +95,44 @@ jobs:
done
no_output_timeout: 38m
+jobs:
+ build:
+ machine:
+ image: ubuntu-2204:current
+ resource_class: large
+ environment:
+ - TZ: "UTC"
+
+ steps:
+ - checkout
+ - install-task
+ - setup-env
+
+ # Start Docker services using Task
+ - run:
+ name: Start Docker services
+ command: |
+ # Set environment variable for CircleCI detection
+ export CIRCLECI=true
+ # Enable Docker Compose bake for build optimization
+ export COMPOSE_BAKE=true
+ # phpunit-only job: skip the ~10-12 min npm/Vite container startup
+ # (the suite outgrew CircleCI's 60-min job cap as one monolith;
+ # the legacy jest+Playwright suites died at the Phase F cutover).
+ export SKIP_NPM_INSTALL=true
+ # Start all services using Task
+ task docker:up-all
+ # This job tests the Laravel side only; the Nuxt client container
+ # is only needed by e2e-client.
+ docker stop restarters_client || true
+ no_output_timeout: 10m
+
+ # Wait for core services to be ready (MySQL + web app). Discourse is started
+ # via docker:up-all but its readiness is checked separately (non-blocking).
+ # Probe an API route: SKIP_NPM_INSTALL means Blade pages 500 (no Vite manifest).
+ - wait-for-core:
+ probe: "http://localhost:8001/api/v2/session"
+
# Setup database and application
- run:
name: Setup application
@@ -116,6 +149,15 @@ jobs:
# Generate additional Laravel artifacts for testing
docker exec restarters php artisan l5-swagger:generate
+ # Fail fast on a broken OpenAPI document (dangling refs, orphaned
+ # security schemes, duplicate operationIds) before running tests.
+ docker exec restarters php tools/openapi-lint.php
+
+ # The committed client locale JSONs must match a fresh export from lang/
+ - run:
+ name: Check client translations in sync
+ command: |
+ docker exec restarters php artisan translations:export-client --check
# Setup Discourse API (PostgreSQL must be ready; Discourse web app not required)
- run:
@@ -141,6 +183,17 @@ jobs:
# Configure Discourse settings (only if Discourse web app is up)
docker exec restarters php artisan discourse:setting personal_message_enabled_groups 10 || echo "Warning: discourse:setting failed - Discourse may not be fully started"
+ # Wait for the Discourse web app itself (non-fatal). Skipping npm
+ # made phpunit start ~12 min earlier, and Discourse-integration
+ # tests crawl against a cold Discourse (5s timeout + retries per
+ # call) — give it the warm-up window it used to get by accident.
+ echo "Waiting for Discourse web app (up to 10 min, non-fatal)..."
+ for i in $(seq 1 120); do
+ curl -f -s http://localhost:8003 >/dev/null 2>&1 && echo "✓ Discourse ready after $((i*5))s" && break
+ [ $((i % 12)) -eq 0 ] && echo " Still waiting for Discourse... ($((i*5))s)"
+ sleep 5
+ done
+
# Run PHPUnit tests
- run:
name: Run PHPUnit tests
@@ -151,49 +204,118 @@ jobs:
docker cp restarters:/tmp/phpunit-results.xml /tmp/test-results/phpunit/results.xml
no_output_timeout: 45m
- # Run Jest tests
+ # Store artifacts
+ - store_artifacts:
+ path: /tmp/test-results
+ destination: playwright-test-results
+
+ - store_test_results:
+ path: /tmp/test-results
+
+ # Nuxt client: lint + unit tests + production build. Fast feedback, no
+ # docker-compose stack needed.
+ build-client:
+ docker:
+ - image: cimg/node:22.16
+ resource_class: medium
+ steps:
+ - checkout
+ - restore_cache:
+ keys:
+ - client-npm-v1-{{ checksum "client/package-lock.json" }}
+ - client-npm-v1-
+ - run:
+ name: Install client dependencies
+ command: cd client && npm ci
+ - save_cache:
+ key: client-npm-v1-{{ checksum "client/package-lock.json" }}
+ paths:
+ - ~/.npm
+ - run:
+ name: Lint
+ command: cd client && npm run lint
- run:
- name: Run Jest tests
+ name: Vitest
command: |
- # Run Jest tests using Task for consistency with local development
- task docker:test:jest
- # Copy test results to host if they exist
- docker cp restarters:/tmp/test-results/junit.xml /tmp/test-results/jest/junit.xml || echo "Jest results not found, skipping"
+ mkdir -p /tmp/test-results/vitest
+ cd client && npx vitest run --reporter=default --reporter=junit --outputFile=/tmp/test-results/vitest/results.xml
+ - run:
+ name: Nuxt build
+ command: cd client && npx nuxi build
+ - store_test_results:
+ path: /tmp/test-results
+ - store_artifacts:
+ path: /tmp/test-results
+ destination: client-test-results
- # Run main Playwright tests (excluding autocomplete)
+ # Playwright against the real two-container stack (Laravel API + Nuxt client).
+ # Separate machine job — the monolithic `build` job's time budget is already
+ # tight, so client e2e gets its own timeout envelope.
+ e2e-client:
+ machine:
+ image: ubuntu-2204:current
+ resource_class: large
+ environment:
+ - TZ: "UTC"
+ steps:
+ - checkout
+ - install-task
+ - setup-env
- run:
- name: Run main Playwright tests
+ name: Start Docker services (core + client, production client build)
command: |
- # Run Playwright tests using Task for consistency with local development
- task docker:test:playwright
+ export CIRCLECI=true
+ export COMPOSE_BAKE=true
+ export NUXT_DEV_MODE=false
+ task docker:up-core
no_output_timeout: 10m
-
- # Copy test results and artifacts
+ # Probe an API route, not the default "/": post-cutover Laravel no longer
+ # serves "/" (the Nuxt/Nitro origin does), so "/" now returns 404 and the
+ # curl -f readiness check would never pass. /api/v2/session is a live API
+ # route (same probe the phpunit build job uses).
+ - wait-for-core:
+ probe: "http://localhost:8001/api/v2/session"
+ - run:
+ name: Wait for Nuxt client
+ command: |
+ echo "Waiting for the client container (npm ci + nuxi build on first boot)..."
+ for i in $(seq 1 120); do
+ curl -f -s http://localhost:3000 >/dev/null 2>&1 && echo "✓ Client ready after $((i*5))s" && exit 0
+ [ $((i % 12)) -eq 0 ] && echo " Still waiting... ($((i*5))s elapsed)"
+ sleep 5
+ done
+ echo "❌ Client not ready after 10 minutes — container logs:"
+ docker logs restarters_client --tail=100 2>&1 || true
+ exit 1
+ no_output_timeout: 12m
+ - run:
+ name: Setup application
+ command: |
+ docker exec restarters_db mysql -u root -ps3cr3t -e "GRANT SELECT ON mysql.time_zone_name TO 'restarters'@'%';"
+ docker exec restarters_db mysql -u root -ps3cr3t -e "SET GLOBAL log_bin_trust_function_creators = 1;"
+ docker exec restarters_db mysql -u root -ps3cr3t -e "SET GLOBAL sql_mode=(SELECT REPLACE(@@sql_mode,'ONLY_FULL_GROUP_BY',''));"
+ docker exec restarters php artisan l5-swagger:generate
+ - run:
+ name: Run client Playwright tests
+ command: |
+ task docker:test:playwright:client
+ no_output_timeout: 15m
- run:
name: Copy Playwright artifacts
command: |
- # Create test results directory on host
- mkdir -p /tmp/test-results/playwright
- mkdir -p /tmp/test-results/logs
-
- # List what's available in the playwright container
- docker exec restarters_playwright bash -c "ls -la /tmp/test-results/* || echo 'No playwright directories found'"
-
- # Copy test results and artifacts from playwright container to host
- docker cp restarters_playwright:/tmp/test-results/. /tmp/test-results/playwright/ || echo "Playwright HTML report not found"
-
- # Copy Vite log from restarters container for debugging
- docker cp restarters:/tmp/vite.log /tmp/test-results/logs/vite.log || echo "Vite log not found"
+ mkdir -p /tmp/test-results/playwright-client
+ docker cp restarters_playwright:/tmp/test-results/. /tmp/test-results/playwright-client/ || echo "No playwright results found"
when: always
-
- # Store artifacts
- store_artifacts:
path: /tmp/test-results
- destination: playwright-test-results
-
+ destination: client-e2e-results
- store_test_results:
path: /tmp/test-results
+ # develop is deployed as a preview, exactly like a PR: same fly.preview.toml,
+ # same preview-startup.sh, same disposable database restored from the latest
+ # hourly production backup, same suspend-on-idle teardown. The only
+ # develop-specific things left are the app name and the custom domain.
deploy-fly-dev:
docker:
- image: cimg/base:current
@@ -205,11 +327,53 @@ jobs:
curl -L https://fly.io/install.sh | sh || true
echo 'export PATH=/home/circleci/.fly/bin:$PATH' >> $BASH_ENV
- run:
- name: Deploy to restarters-dev
+ name: Deploy develop preview to restarters-dev
command: |
export FLY_API_TOKEN="FlyV1 $FLY_API_TOKEN"
- flyctl deploy --config fly.dev.toml --remote-only
+ # Keep this substitution list in step with the one in
+ # .github/workflows/pr-preview.yml.
+ sed -e "s|__APP_NAME__|restarters-dev|g" \
+ -e "s|__APP_TITLE__|Restarters develop|g" \
+ -e "s|__BASE_URL__|https://restarters-dev.fly.dev|g" \
+ -e "s|__SENTRY_ENV__|dev|g" \
+ fly.preview.toml > fly.develop.generated.toml
+ if grep -q '__[A-Z_]*__' fly.develop.generated.toml; then
+ echo "Unsubstituted placeholder left in the generated config:"
+ grep -n '__[A-Z_]*__' fly.develop.generated.toml
+ exit 1
+ fi
+ flyctl deploy --config fly.develop.generated.toml \
+ --app restarters-dev --remote-only --ha=false
+ # --ha=false has a history of not reliably keeping one machine.
+ flyctl scale count 1 --app restarters-dev --yes
no_output_timeout: 15m
+ - run:
+ name: Wait for restore and migrations
+ command: |
+ # Cold boot restores ~98MB of backup then migrates; until that
+ # finishes visitors get the warming page. Fail the build if the
+ # branch's migrations do not apply to real production data.
+ # preview-startup.sh writes the status with printf as one flat
+ # line, so sed reads it without needing jq on the image.
+ field() { echo "$1" | sed -n "s/.*\"$2\":\"\([^\"]*\)\".*/\1/p"; }
+ PHASE=""
+ for i in $(seq 1 80); do
+ BODY=$(curl -fsS --max-time 10 https://restarters-dev.fly.dev/_preview_status 2>/dev/null) || BODY=""
+ PHASE=$(field "$BODY" phase)
+ echo "poll $i: phase='${PHASE:-n/a}'"
+ case "$PHASE" in
+ ready|failed) break ;;
+ esac
+ sleep 15
+ done
+ if [ "$PHASE" != "ready" ]; then
+ echo "restarters-dev.fly.dev did not become ready (phase='${PHASE:-unknown}')."
+ echo "detail: $(field "$BODY" detail)"
+ echo "Check: flyctl logs --app restarters-dev"
+ exit 1
+ fi
+ echo "restarters-dev.fly.dev is ready."
+ no_output_timeout: 25m
deploy-fly-prod:
docker:
@@ -235,9 +399,19 @@ workflows:
filters:
branches:
ignore: production
+ - build-client:
+ filters:
+ branches:
+ ignore: production
+ - e2e-client:
+ filters:
+ branches:
+ ignore: production
- deploy-fly-dev:
requires:
- build
+ - build-client
+ - e2e-client
filters:
branches:
only: develop
diff --git a/.dockerignore b/.dockerignore
index efeaacc58d..19defa95bf 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -1,4 +1,7 @@
-node_modules
+# Docker only applies a bare directory name at the top level, so `node_modules`
+# alone never excluded client/node_modules (~466MB) — it was uploaded to the
+# builder on every deploy, CI included. Use ** for anything nested.
+**/node_modules
vendor
.git
.env*
@@ -14,4 +17,25 @@ tests/Browser/screenshots
tests/Browser/console
*.log
.DS_Store
-Thumbs.db
\ No newline at end of file
+Thumbs.db
+
+# Local-only clutter. These never exist in a CI checkout, but a manual
+# `flyctl deploy` from a working tree uploads them: together they took the
+# build context to 8.1GB / 358k files and made the deploy time out.
+.worktrees/
+.claude/
+.idea/
+.vscode/
+
+# Test/report output. All untracked, so a CI checkout never has them, but a
+# local deploy would ship ~253MB of screenshots and reports.
+playwright-report/
+parity-shots/
+test-results/
+/uploads/
+
+# Root-level database dumps (e.g. restarters-anonymised.sql, ~662MB).
+# Anchored to the root on purpose: database/data_updates/*.sql is tracked and
+# must stay in the image.
+/*.sql
+/*.sql.gz
diff --git a/.github/workflows/pr-preview.yml b/.github/workflows/pr-preview.yml
index f715ccbba0..a99052f165 100644
--- a/.github/workflows/pr-preview.yml
+++ b/.github/workflows/pr-preview.yml
@@ -3,7 +3,7 @@
# Deploy: label a PR with 'preview' (or re-push while labeled, or run this
# workflow manually with a PR number). Every deploy waits for
# approval via the 'preview' GitHub Environment — approvers should
-# glance at the PR's diff to .github/workflows/, fly.pr.toml,
+# glance at the PR's diff to .github/workflows/, fly.preview.toml,
# Dockerfile.fly and docker/*.sh before approving, since the deploy
# runs the PR's own code.
# Cleanup: a 6-hourly sweep destroys apps whose PR is closed or no longer
@@ -116,7 +116,7 @@ jobs:
- name: Overlay preview infra files from develop if still missing
run: |
git fetch origin develop --depth=1
- for f in fly.pr.toml docker/preview-startup.sh; do
+ for f in fly.preview.toml docker/preview-startup.sh; do
if [ ! -f "$f" ]; then
echo "Branch lacks $f - taking it from develop"
git show origin/develop:"$f" > "$f"
@@ -153,7 +153,7 @@ jobs:
else
flyctl apps create "$APP" --org "$FLY_ORG"
# Shared read-only credentials (Tigris RO, Drive RO, gate password...)
- # Strip comments/blank lines so the fly.pr-secrets.example.env
+ # Strip comments/blank lines so the fly.preview-secrets.example.env
# template can be filled in and pasted as-is.
echo "$FLY_PREVIEW_SECRETS" | grep -vE '^\s*(#|$)' \
| flyctl secrets import --app "$APP" --stage
@@ -171,8 +171,14 @@ jobs:
PR="${{ steps.pr.outputs.number }}"
# Shown on the cookie-gate login page so testers know which PR this is.
flyctl secrets set PREVIEW_PR_TITLE="$PR_TITLE" --app "$APP" --stage
- sed "s/__PR_NUMBER__/$PR/g" fly.pr.toml > fly.pr.generated.toml
- flyctl deploy --config fly.pr.generated.toml --app "$APP" \
+ # fly.preview.toml is shared with the develop deploy in
+ # .circleci/config.yml; keep the two substitution lists in step.
+ sed -e "s|__APP_NAME__|$APP|g" \
+ -e "s|__APP_TITLE__|Restarters PR #$PR|g" \
+ -e "s|__BASE_URL__|https://$APP.fly.dev|g" \
+ -e "s|__SENTRY_ENV__|preview|g" \
+ fly.preview.toml > fly.preview.generated.toml
+ flyctl deploy --config fly.preview.generated.toml --app "$APP" \
--remote-only --ha=false
# --ha=false has a history of not reliably keeping one machine.
flyctl scale count 1 --app "$APP" --yes
diff --git a/.github/workflows/stop-dev-nightly.yml b/.github/workflows/stop-dev-nightly.yml
deleted file mode 100644
index e7e3d0d68c..0000000000
--- a/.github/workflows/stop-dev-nightly.yml
+++ /dev/null
@@ -1,47 +0,0 @@
-name: Stop restarters-dev nightly
-
-# Stops the restarters-dev Fly machine(s) every night to avoid paying for the
-# dev/staging box while it sits idle overnight.
-#
-# How it stays cheap but stays usable:
-# * fly.dev.toml has auto_stop_machines = "off", so Fly never auto-stops the
-# box during the day AND never auto-restarts a machine we stop here
-# (min_machines_running is only enforced when auto_stop is enabled, so it is
-# inert for us — verified: a manual `fly machine stop` stays stopped).
-# * fly.dev.toml has auto_start_machines = true, so the machine boots again
-# automatically the first time anyone makes a request in the morning.
-#
-# Net effect: off overnight (no CPU billing), back on demand. A stopped machine
-# still keeps its volume, so no data is lost.
-#
-# NOTE ON TIME: GitHub Actions cron is UTC and does NOT follow BST/DST.
-# 0 2 * * * == 02:00 UTC == 03:00 London in summer (BST) / 02:00 in winter.
-# Adjust the cron if you want exactly 02:00 London year-round.
-
-on:
- schedule:
- - cron: '0 2 * * *' # 02:00 UTC daily
- workflow_dispatch: {} # allow manual runs from the Actions tab
-
-jobs:
- stop-dev:
- runs-on: ubuntu-latest
- steps:
- # Pinned to a full commit SHA (supply-chain hardening; SonarCloud S7637).
- # ed8efb3 = superfly/flyctl-actions master as of 2026-07-03.
- - uses: superfly/flyctl-actions/setup-flyctl@ed8efb33836e8b2096c7fd3ba1c8afe303ebbff1
- - name: Stop all restarters-dev machines
- env:
- FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
- run: |
- set -euo pipefail
- ids=$(flyctl machine list -a restarters-dev --json | jq -r '.[].id')
- if [ -z "$ids" ]; then
- echo "No machines found for restarters-dev — nothing to stop."
- exit 0
- fi
- for id in $ids; do
- echo "Stopping machine $id"
- flyctl machine stop "$id" -a restarters-dev
- done
- echo "Done. Machines will auto-start on the next request."
diff --git a/.gitignore b/.gitignore
index e2459d1265..026fcba8e9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -32,9 +32,29 @@ restarters-anonymised.sql
/.claude/settings.local.json
/public/build/
/storage/api-docs/api-docs.json
+/storage/app/tus
+/storage/app/tus-cache
/.phpunit.result.cache
/playwright-report/
/events.csv
/public/repair-data*.csv
/uploads/*.jpg
.mcp.json
+
+# Local ralph session log (not for commit)
+.claude-session.md
+# PHPUnit coverage artifact
+/tests/clover.xml
+
+# Visual parity capture output (task parity:capture) - screenshots, never committed
+parity-shots/
+
+# Canvas share-image backgrounds (224MB, 267 files). These live in the Laravel
+# app at public/images/stats and must NOT be duplicated into the client bundle.
+# The client reaches them through an /api/* endpoint so CORS applies and the
+# canvas stays untainted - see the stats-share composable.
+client/public/images/stats/
+parity-fixtures.json
+
+# Generated from fly.preview.toml by CI (and by hand for manual deploys)
+fly.*.generated.toml
diff --git a/.php_cs.cache b/.php_cs.cache
new file mode 100644
index 0000000000..49927cc2fd
--- /dev/null
+++ b/.php_cs.cache
@@ -0,0 +1 @@
+{"php":"8.4.21","version":"2.19.1","indent":" ","lineEnding":"\n","rules":{"array_syntax":{"syntax":"short"},"binary_operator_spaces":{"default":"single_space","operators":{"=>":null}},"blank_line_after_namespace":true,"blank_line_after_opening_tag":true,"blank_line_before_statement":{"statements":["return"]},"braces":true,"cast_spaces":true,"class_attributes_separation":{"elements":["method"]},"class_definition":true,"concat_space":{"spacing":"none"},"declare_equal_normalize":true,"elseif":true,"encoding":true,"full_opening_tag":true,"fully_qualified_strict_types":true,"function_declaration":true,"function_typehint_space":true,"heredoc_to_nowdoc":true,"include":true,"increment_style":{"style":"post"},"indentation_type":true,"linebreak_after_opening_tag":true,"line_ending":true,"lowercase_cast":true,"lowercase_constants":true,"lowercase_keywords":true,"lowercase_static_reference":true,"magic_method_casing":true,"magic_constant_casing":true,"method_argument_space":true,"native_function_casing":true,"no_alias_functions":true,"no_extra_blank_lines":{"tokens":["extra","throw","use","use_trait"]},"no_blank_lines_after_class_opening":true,"no_blank_lines_after_phpdoc":true,"no_closing_tag":true,"no_empty_phpdoc":true,"no_empty_statement":true,"no_leading_import_slash":true,"no_leading_namespace_whitespace":true,"no_mixed_echo_print":{"use":"echo"},"no_multiline_whitespace_around_double_arrow":true,"multiline_whitespace_before_semicolons":{"strategy":"no_multi_line"},"no_short_bool_cast":true,"no_singleline_whitespace_before_semicolons":true,"no_spaces_after_function_name":true,"no_spaces_around_offset":true,"no_spaces_inside_parenthesis":true,"no_trailing_comma_in_list_call":true,"no_trailing_comma_in_singleline_array":true,"no_trailing_whitespace":true,"no_trailing_whitespace_in_comment":true,"no_unneeded_control_parentheses":true,"no_unreachable_default_argument_value":true,"no_useless_return":true,"no_whitespace_before_comma_in_array":true,"no_whitespace_in_blank_line":true,"normalize_index_brace":true,"not_operator_with_successor_space":true,"object_operator_without_whitespace":true,"ordered_imports":{"sortAlgorithm":"alpha"},"phpdoc_indent":true,"phpdoc_inline_tag":true,"phpdoc_no_access":true,"phpdoc_no_package":true,"phpdoc_no_useless_inheritdoc":true,"phpdoc_scalar":true,"phpdoc_single_line_var_spacing":true,"phpdoc_summary":true,"phpdoc_to_comment":true,"phpdoc_trim":true,"phpdoc_types":true,"phpdoc_var_without_name":true,"psr4":true,"self_accessor":true,"short_scalar_cast":true,"single_blank_line_at_eof":true,"single_blank_line_before_namespace":true,"single_class_element_per_statement":true,"single_import_per_statement":true,"single_line_after_imports":true,"single_line_comment_style":{"comment_types":["hash"]},"single_quote":true,"space_after_semicolon":true,"standardize_not_equals":true,"switch_case_semicolon_to_colon":true,"switch_case_space":true,"ternary_operator_spaces":true,"trailing_comma_in_multiline_array":true,"trim_array_spaces":true,"unary_operator_spaces":true,"visibility_required":{"elements":["method","property"]},"whitespace_after_comma_in_array":true},"hashes":{"app\/Http\/Controllers\/API\/StatsShareImageController.php":653998297,"tests\/Feature\/Devices\/APIv2StatsShareImageTest.php":3643096653,"routes\/api.php":180907154}}
\ No newline at end of file
diff --git a/CLAUDE.md b/CLAUDE.md
index 11604aabab..b6d03de560 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -25,9 +25,9 @@ task docker:run:artisan -- migrate # Run artisan command
task docker:shell # Open shell in container
# Testing
-task docker:test:phpunit # Run PHP tests
-task docker:test:jest # Run JS tests
-task docker:test:playwright # Run e2e tests
+task docker:test:phpunit # Run PHP tests
+task docker:test:vitest # Run client (Nuxt) unit tests
+task docker:test:playwright:client # Run client e2e tests
# Vite dev server (HMR)
task docker:vite:start # Start Vite in background
@@ -74,11 +74,9 @@ task docker:shell # Open shell in container
task docker:run:artisan -- migrate # Run artisan commands
task docker:run:bash -- "command" # Run bash commands
-# Run Vite for HMR (hot module replacement)
-task docker:run:bash -- "npm run dev"
-
# The application will be available at:
-# - Restarters: http://localhost:8001 (Admin: jane@bloggs.net / passw0rd)
+# - Frontend (Nuxt SPA, restarters_client): http://localhost:8004 (Admin: jane@bloggs.net / passw0rd)
+# - Laravel API: http://localhost:8001
# - phpMyAdmin: http://localhost:8002 (Host: restarters_db, User: root, Pass: s3cr3t)
# - Mailhog: http://localhost:8025
# - Discourse: http://localhost:8003
@@ -89,11 +87,13 @@ task docker:run:bash -- "npm run dev"
# Install PHP dependencies
composer install
-# Install and build frontend assets
-npm install
-npm run dev # Development build
-npm run watch # Watch for changes
-npm run production # Production build
+# Frontend (Nuxt SPA) — the restarters_client container runs the dev server;
+# for local tooling outside Docker:
+cd client && npm install && npm run dev
+
+# Widget/wiki assets still built by Laravel-side Vite (rarely needed):
+npm install --legacy-peer-deps
+npm run build
# Laravel commands
php artisan migrate # Run database migrations
@@ -121,11 +121,11 @@ export DB_TEST_HOST=restarters_db
# Run specific test method
./vendor/bin/phpunit --filter testMethodName
-# Run JavaScript tests
-npm run jest
+# Run client unit tests (from client/)
+cd client && npx vitest run
-# Run Playwright end-to-end tests
-npm test
+# Run client Playwright end-to-end tests
+task docker:test:playwright:client
```
### Code Quality
@@ -168,11 +168,14 @@ npm test
- JSON columns for flexible network_data storage
### Frontend Stack
-- **Vue 2** components for interactive features
-- **Laravel Mix** for asset compilation
-- **Bootstrap 4** for styling
-- **SCSS** for styles in `resources/sass/`
-- Multiple build targets: main app, global styles, wiki styles
+- **Nuxt 4 SPA in `client/`** — Vue 3, Pinia, bootstrap-vue-next (Bootstrap 5),
+ @nuxtjs/i18n, Vitest; talks to Laravel exclusively via `/api/v2`
+- Laravel serves no user-facing pages (see `tests/Feature/ApiOnlyRouteSurfaceTest.php`
+ for the pinned web-route surface): only the embeddable stats widgets and the
+ MediaWiki skin assets are still built here (Vite, `resources/global` +
+ `resources/wiki`, jQuery/Bootstrap 4 only)
+- Client translations are exported from `lang/*.php` via
+ `php artisan translations:export-client` (checked in CI)
### Permission System
- Role-based permissions via custom `app/Role.php` and `app/Permissions.php`
diff --git a/Dockerfile.fly b/Dockerfile.fly
index 17720324d9..857e9cb1c1 100644
--- a/Dockerfile.fly
+++ b/Dockerfile.fly
@@ -17,8 +17,9 @@ RUN pecl install channel://pecl.php.net/xmlrpc-1.0.0RC3 && docker-php-ext-enable
# Install composer
COPY --from=composer/composer:2-bin /composer /usr/bin/composer
-# Install Node 18
-RUN curl -fsSL https://deb.nodesource.com/setup_18.x | bash - \
+# Install Node 22 (Nuxt 4 / nuxi require Node >=22; matches client/Dockerfile
+# node:22 and CI cimg/node:22.16. Vite 4 root build is Node-22-compatible too.)
+RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
&& apt-get install -y nodejs \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
@@ -53,6 +54,19 @@ RUN php artisan package:discover --ansi || true
RUN php artisan lang:js --no-lib resources/js/translations.js 2>/dev/null || true
RUN npm run production && rm -f public/hot
+# Build the Nuxt SPA (the separate app in client/). `nuxt build` emits a
+# standalone Nitro Node server at client/.output that bundles its own runtime
+# deps, so production serves the SPA origin via
+# `node client/.output/server/index.mjs` (see docker/supervisord-fly.conf).
+# apiBase is runtime-overridable (NUXT_PUBLIC_API_BASE), so no API URL is baked
+# in here. Drop client/node_modules afterwards — .output is self-contained, and
+# this keeps the final-stage `COPY /build` from dragging the dev install in.
+WORKDIR /build/client
+RUN npm ci --legacy-peer-deps
+RUN npm run build
+RUN rm -rf node_modules
+WORKDIR /build
+
# Generate swagger docs (non-fatal if it fails)
RUN php artisan l5-swagger:generate 2>/dev/null || true
@@ -77,6 +91,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
util-linux \
default-mysql-client \
rclone \
+ curl ca-certificates gnupg \
libpng-dev libjpeg62-turbo-dev libfreetype6-dev libzip-dev libicu-dev libxml2-dev \
&& if [ "$STARTUP_SCRIPT" != "startup.sh" ]; then apt-get install -y --no-install-recommends default-mysql-server; fi \
&& docker-php-ext-configure gd --with-freetype --with-jpeg \
@@ -86,6 +101,13 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
# Install xmlrpc
RUN pecl install channel://pecl.php.net/xmlrpc-1.0.0RC3 && docker-php-ext-enable xmlrpc
+# Node 22 (runtime only) — runs the Nuxt Nitro server that serves the SPA origin
+# (client/.output, started by supervisord). The .output targets Node >=22, same
+# as the builder. This image only needs the node binary to run it.
+RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
+ && apt-get install -y --no-install-recommends nodejs \
+ && apt-get clean && rm -rf /var/lib/apt/lists/*
+
# Configure PHP-FPM to use unix socket.
# The base image ships docker.conf (formerly zz-docker.conf) with:
# listen = 9000 — overrides www.conf (last-file-wins in glob)
diff --git a/Taskfile.yml b/Taskfile.yml
index 772e1a3240..e8aeddc78c 100644
--- a/Taskfile.yml
+++ b/Taskfile.yml
@@ -171,6 +171,21 @@ tasks:
cmds:
- docker exec -it restarters php artisan "{{ .CLI_ARGS }}"
+ docker:swagger:generate:
+ desc: Regenerate the OpenAPI (Swagger) spec from the @OA annotations.
+ summary: |
+ Regenerate storage/api-docs/api-docs.json from the @OA annotations under app/.
+ The spec is a gitignored build artifact; CI regenerates it before the PHPUnit
+ and Playwright jobs. Run this after editing any @OA annotation.
+ cmds:
+ - docker exec restarters php artisan l5-swagger:generate
+
+ docker:swagger:lint:
+ desc: Regenerate the OpenAPI spec and check document-level integrity (dangling refs, security schemes, duplicate operationIds).
+ cmds:
+ - docker exec restarters php artisan l5-swagger:generate
+ - docker exec restarters php tools/openapi-lint.php
+
docker:vite:
desc: Start Vite dev server with HMR in the container.
summary: |
@@ -219,75 +234,152 @@ tasks:
cmds:
- mkdir -p /tmp/test-results/phpunit
- |
- if [ -z "$CIRCLECI" ]; then
- echo "Resetting test database and running migrations..."
- docker exec restarters bash -c "php artisan migrate:fresh --seed --database=mysql_testing --force"
+ # Always reset the test DB here (CI included). CI used to skip this,
+ # relying on the container-startup migrate:fresh — but that runs with
+ # no `set -e`, so a startup migration failure silently left the CI
+ # test DB missing the newest tables (seen 2026-07-16: sso_tickets
+ # absent while every earlier table existed). Explicit reset right
+ # before phpunit is deterministic and matches local behaviour.
+ echo "Resetting test database and running migrations..."
+ docker exec restarters bash -c "php artisan migrate:fresh --seed --database=mysql_testing --force"
+ - |
+ # Coverage only where it's consumed: Coveralls tracks develop. The
+ # CircleCI plan caps jobs at 60 minutes and the develop build already
+ # takes ~54 — coverage-instrumented runs of the grown suite on feature
+ # branches blew the cap (three kills at exactly 60.0 min, mid-test).
+ # NOTE (pre-merge cliff, tracked in plan G1): once this branch merges,
+ # develop's suite grows too — the build job needs a split or a plan
+ # bump before then.
+ if [ -n "$COVERALLS_REPO_TOKEN" ] && { [ -z "$CIRCLE_BRANCH" ] || [ "$CIRCLE_BRANCH" = "develop" ]; }; then
+ docker exec -e COVERALLS_REPO_TOKEN="$COVERALLS_REPO_TOKEN" restarters bash -c "export XDEBUG_MODE=coverage; ./vendor/bin/phpunit -d memory_limit=1024M --bootstrap vendor/autoload.php --coverage-clover tests/clover.xml --log-junit /tmp/phpunit-results.xml --configuration ./phpunit.xml --teamcity {{ .CLI_ARGS }}"
else
- echo "Running on CircleCI - skipping database reset (handled by CI setup)"
+ docker exec restarters bash -c "export XDEBUG_MODE=off; ./vendor/bin/phpunit -d memory_limit=1024M --bootstrap vendor/autoload.php --log-junit /tmp/phpunit-results.xml --configuration ./phpunit.xml --teamcity {{ .CLI_ARGS }}"
fi
- - docker exec -e COVERALLS_REPO_TOKEN="$COVERALLS_REPO_TOKEN" restarters bash -c "export XDEBUG_MODE=coverage; ./vendor/bin/phpunit -d memory_limit=1024M --bootstrap vendor/autoload.php --coverage-clover tests/clover.xml --log-junit /tmp/phpunit-results.xml --configuration ./phpunit.xml --teamcity {{ .CLI_ARGS }}"
- |
- if [ ! -z "$COVERALLS_REPO_TOKEN" ]; then
+ # Mirror the generation gate above: no clover.xml is produced on
+ # non-develop CI branches, so there is nothing to upload there.
+ if [ -n "$COVERALLS_REPO_TOKEN" ] && { [ -z "$CIRCLE_BRANCH" ] || [ "$CIRCLE_BRANCH" = "develop" ]; }; then
echo "Uploading coverage to Coveralls..."
docker exec -e COVERALLS_REPO_TOKEN="$COVERALLS_REPO_TOKEN" restarters bash -c "./upload-coverage.sh tests/clover.xml"
else
echo "COVERALLS_REPO_TOKEN not set, skipping coverage upload"
fi
- docker:test:jest:
- desc: Run Jest tests in the core application container.
- summary: |
- Run Jest tests with JUnit output for CI integration.
- This ensures consistency between local development and CI environments.
+ docker:client:logs:
+ desc: Tail the Nuxt client container logs.
+ cmds:
+ - docker logs --follow restarters_client
+ docker:client:restart:
+ desc: Restart the Nuxt client container (e.g. after changing client env vars).
cmds:
- - mkdir -p /tmp/test-results/jest
- - docker exec restarters bash -c "npm i jest-junit; JEST_JUNIT_OUTPUT_DIR=/tmp/test-results npm run jest -- --testResultsProcessor=jest-junit"
+ - "{{.DOCKER_CMD}} --profile core restart restarters_client"
- docker:test:playwright:
- desc: Run Playwright tests in dedicated playwright container.
+ docker:test:vitest:
+ desc: Run the client Vitest suite in the client container.
summary: |
- Run Playwright end-to-end tests using a dedicated playwright container.
- This ensures proper isolation and reliable network access to the application.
-
- The command includes:
- - Environment variables for test configuration
- - HTML report generation
- - Proper test isolation
+ Runs the Nuxt client's unit tests with JUnit output for CI integration.
+ cmds:
+ - mkdir -p /tmp/test-results/vitest
+ - docker exec restarters_client npx vitest run --reporter=default --reporter=junit --outputFile=test-results/vitest.xml
+ - cp client/test-results/vitest.xml /tmp/test-results/vitest/results.xml
+ playwright:seed-data:
+ desc: Seed the fixed users/network/group the Playwright suites depend on.
+ internal: true
cmds:
- - mkdir -p /tmp/test-results/playwright
- docker exec restarters bash -c "php artisan cache:clear"
- docker exec restarters bash -c "echo \"DB::statement('SET foreign_key_checks=0'); App\\\\Device::truncate(); DB::statement('SET foreign_key_checks=1');\" | php artisan tinker" || true
- docker exec restarters bash -c "echo \"\\\$u=App\\\\User::firstOrCreate(['email'=>'jane@bloggs.net'], ['name'=>'Jane Bloggs','password'=>Hash::make('passw0rd'),'consent_past_data'=>'2021-01-01','consent_future_data'=>'2021-01-01','consent_gdpr'=>'2021-01-01']);\\\$u->role=2;\\\$u->save();\" | php artisan tinker" || true
- |
# Create test data for group tags tests (NC user, host user, network, group)
docker exec restarters php artisan tinker --execute='$nc = App\User::firstOrCreate(["email"=>"nc@test.net"], ["name"=>"NC User","password"=>Hash::make("passw0rd"),"consent_past_data"=>"2021-01-01","consent_future_data"=>"2021-01-01","consent_gdpr"=>"2021-01-01"]); $nc->role=6; $nc->save(); $host = App\User::firstOrCreate(["email"=>"host@test.net"], ["name"=>"Host User","password"=>Hash::make("passw0rd"),"consent_past_data"=>"2021-01-01","consent_future_data"=>"2021-01-01","consent_gdpr"=>"2021-01-01"]); $host->role=3; $host->save(); $network = App\Network::where("name","Test London")->first(); if (!$network) { $network = new App\Network(); $network->name="Test London"; $network->shortname="testlondon"; $network->description="Test network"; $network->default_language="en"; $network->save(); } DB::table("user_network")->insertOrIgnore(["user_id"=>$nc->id,"network_id"=>$network->id]); $group = App\Group::firstOrCreate(["name"=>"Tag Test Group"], ["website"=>"https://test.example.com","location"=>"London","area"=>"London","postcode"=>"SW1A 1AA","latitude"=>51.5,"longitude"=>-0.1,"free_text"=>"Test group","approved"=>true]); DB::table("group_network")->insertOrIgnore(["group_id"=>$group->idgroups,"network_id"=>$network->id]); DB::table("users_groups")->insertOrIgnore(["user"=>$host->id,"group"=>$group->idgroups,"role"=>3,"status"=>1]); echo "Group tags test data: NC=".$nc->id." Host=".$host->id." Network=".$network->id." Group=".$group->idgroups;' || true
- - docker exec restarters bash -c "sed -i 's/.throttle:api.,//g' /var/www/app/Http/Kernel.php"
- docker exec restarters bash -c "sed -i 's/APP_DEBUG=.*$/APP_DEBUG=false/g' /var/www/.env"
- docker exec restarters bash -c "sed -i 's/QUEUE_CONNECTION=.*/QUEUE_CONNECTION=sync/g' /var/www/.env"
- docker exec restarters bash -c "sed -i 's/MAIL_MAILER=.*/MAIL_MAILER=log/g' /var/www/.env"
+
+ docker:test:playwright:client:
+ desc: Run the client Playwright suite against the Nuxt container.
+ summary: |
+ End-to-end tests for the Nuxt SPA (client/e2e, playwright.client.config.js).
+ The browser loads the SPA from the restarters_client container and the SPA
+ calls the Laravel API via www.example.com:8001 (host-gateway).
+ cmds:
+ - mkdir -p /tmp/test-results/playwright
+ - task: playwright:seed-data
- |
- # Stop Vite dev server and build assets for production
- echo "Stopping Vite dev server and building assets..."
- docker exec restarters bash -c "ps aux | grep 'node.*vite' | grep -v grep | awk '{print \$2}' | xargs -r kill || true"
- docker exec restarters bash -c "rm -f /var/www/public/hot && npm run build"
- - |
- # Ensure Playwright browsers are installed (handles version mismatches)
- echo "Installing Playwright browsers..."
- docker exec restarters_playwright bash -c "npx playwright install chromium"
+ # Relax the auth-endpoint rate limit for test bursts (named limiter
+ # reads AUTH_RATE_LIMIT; .env edits are the established pattern here).
+ docker exec restarters bash -c "grep -q '^AUTH_RATE_LIMIT' /var/www/.env && sed -i 's/^AUTH_RATE_LIMIT=.*/AUTH_RATE_LIMIT=1000/' /var/www/.env || echo 'AUTH_RATE_LIMIT=1000' >> /var/www/.env; php artisan config:clear"
+ - docker exec restarters_playwright bash -c "npx playwright install chromium"
- |
docker exec restarters_playwright bash -c "
export PLAYWRIGHT_TEST=true
- export PLAYWRIGHT_DEBUG=true
- export PWTEST_SKIP_TEST_OUTPUT=0
- export DEBUG=playwright
- export PLAYWRIGHT_BASE_URL=http://restarters_nginx
+ export PLAYWRIGHT_BASE_URL=http://restarters_client:3000
+ export PLAYWRIGHT_API_URL=http://www.example.com:8001
export PW_TEST_HTML_REPORT_OPEN=never
export FORCE_COLOR=1
- stdbuf -oL -eL npx playwright test --reporter=html
+ stdbuf -oL -eL npx playwright test --config=playwright.client.config.js --reporter=html {{ .CLI_ARGS }}
"
+ parity:capture:
+ desc: Capture matched dev-vs-live screenshot pairs for visual parity diffing.
+ summary: |
+ Drives BOTH LOCAL DEV systems through the same page list at desktop +
+ mobile, writing matched pairs to
+ parity-shots/