diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 2539c57..09e34c0 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -95,3 +95,7 @@ jobs: - name: Bridge-integration test (HTTP/SSE round-trip + crash-recover) working-directory: examples/todo run: ./tests/integration.sh + + - name: Bundled-mode supervisor integration test + working-directory: examples/todo + run: make integration-bundled diff --git a/examples/todo/Makefile b/examples/todo/Makefile index 6188102..c6d0e6c 100644 --- a/examples/todo/Makefile +++ b/examples/todo/Makefile @@ -40,12 +40,16 @@ clean: ## Remove build artefacts integration: ## Run the bridge-integration test (FrankenPHP boot + HTTP/SSE round-trip + crash-recover) ./tests/integration.sh +.PHONY: integration-bundled +integration-bundled: build staging-symfony ## Bundled-mode integration test (faked AppImage layout, no .AppImage build needed) + ./tests/bundled-supervisor.sh + .PHONY: perf perf: ## Run the AppImage perf smoke test (PLAN.md §11 budgets) ./tests/perfsmoke.sh build/Todo-x86_64.AppImage -.PHONY: appimage -appimage: build ## Package as a single-file Linux AppImage at build/Todo-x86_64.AppImage +.PHONY: staging-symfony +staging-symfony: ## Stage a --no-dev composer copy of symfony for AppImage / bundled-mode tests # Composer install --no-dev in a staging copy of symfony so the # dev tree (with maker-bundle etc.) is left untouched. rm -rf build/staging-symfony @@ -62,6 +66,9 @@ appimage: build ## Package as a single-file Linux AppImage at build/Todo-x86_64. sed -i 's|"symlink": true|"symlink": false|' build/staging-symfony/composer.json rm -f build/staging-symfony/composer.lock cd build/staging-symfony && composer install --no-dev --no-interaction --classmap-authoritative + +.PHONY: appimage +appimage: build staging-symfony ## Package as a single-file Linux AppImage at build/Todo-x86_64.AppImage ../../packaging/linux/build-appimage.sh \ --app-name todo \ --host-binary $(QT_BIN) \ @@ -76,7 +83,8 @@ appimage: build ## Package as a single-file Linux AppImage at build/Todo-x86_64. @echo "AppImage built. Test with: ./build/Todo-x86_64.AppImage" .PHONY: quality -quality: build ## Run PHPStan, php-cs-fixer (check), PHPUnit, qmllint, integration +quality: build ## Run PHPStan, php-cs-fixer (check), PHPUnit, qmllint, integration (dev + bundled) cd ../../framework/php && composer quality cmake --build $(BUILD_DIR) --target all_qmllint ./tests/integration.sh + $(MAKE) integration-bundled diff --git a/examples/todo/tests/bundled-supervisor.sh b/examples/todo/tests/bundled-supervisor.sh new file mode 100755 index 0000000..81f3e89 --- /dev/null +++ b/examples/todo/tests/bundled-supervisor.sh @@ -0,0 +1,141 @@ +#!/usr/bin/env bash +# Bundled-mode integration test (v0.1.1). +# +# Exercises the bundled-mode supervisor codepath end-to-end *without* +# requiring a real AppImage build: +# +# - resolveFrankenphpBin (BackendConnection.cpp) — finds frankenphp +# as a sibling of the host binary at usr/bin/frankenphp. +# - resolveSymfonyDir / resolveCaddyfilePath — finds the staged +# Symfony tree + Caddyfile under usr/share//. +# - runMigrations + spawnChild — supervisor drives the doctrine +# migrate, spawns frankenphp, polls /healthz. +# - Kernel::getCacheDir / getLogDir override — Symfony writes to +# the user data dir, not the (chmod -w) staged tree. +# - HealthController deep-load — /healthz response includes a +# `bundle` field proving BridgeBundle was autoloaded. +# +# Catches the v0.1.0 shakedown bugs (doubled bin/frankenphp path, +# composer path-repo symlink dangling at runtime, read-only mount +# var/cache failure) faster than perfsmoke against a real .AppImage. +# +# Designed for `make integration-bundled`. Expects the regular +# `make build` artefacts to exist; runs `make staging-symfony` +# itself if the staged tree isn't present. +# +# Skip-conditions: +# - port 8765 already in use (don't trample a dev instance) +# - frankenphp not on PATH + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +APP_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +BUILD_DIR="$APP_DIR/build/qml" +HOST_BIN="$BUILD_DIR/todo" +STAGING="$APP_DIR/build/staging-symfony" +CADDYFILE="$APP_DIR/Caddyfile" + +APP_NAME=todo +PORT=8765 + +step() { echo "→ $*"; } +fail() { echo "✗ FAIL: $*" >&2; exit 1; } +skip() { echo "⊘ SKIP: $*" >&2; exit 0; } + +# ── Pre-flight ───────────────────────────────────────────────────────── +[ -x "$HOST_BIN" ] || fail "host binary not built — run 'make build' first ($HOST_BIN)" +command -v frankenphp >/dev/null 2>&1 || skip "frankenphp not on PATH" +[ -d "$STAGING" ] || { step "no staging-symfony, building it"; (cd "$APP_DIR" && make staging-symfony >/dev/null); } + +if (echo > "/dev/tcp/127.0.0.1/$PORT") 2>/dev/null; then + skip "port $PORT already in use (dev instance running?)" +fi + +# ── Stage a fake AppImage layout in a temp dir ───────────────────────── +ROOT="$(mktemp -d)" +DATA_DIR="$(mktemp -d)" +trap 'cleanup' EXIT INT TERM + +PID="" +cleanup() { + trap - EXIT INT TERM + if [ -n "$PID" ] && kill -0 "$PID" 2>/dev/null; then + kill -TERM "$PID" 2>/dev/null || true + for _ in 1 2 3 4 5 6 7 8 9 10; do + kill -0 "$PID" 2>/dev/null || break + sleep 0.2 + done + kill -KILL "$PID" 2>/dev/null || true + fi + # Restore writable so rm -rf doesn't choke. + [ -d "$ROOT/usr/share/$APP_NAME/symfony" ] && \ + chmod -R u+w "$ROOT/usr/share/$APP_NAME/symfony" 2>/dev/null || true + rm -rf "$ROOT" "$DATA_DIR" +} + +step "stage AppImage layout at $ROOT" +mkdir -p "$ROOT/usr/bin" "$ROOT/usr/share/$APP_NAME" +# Host binary must be copied, not symlinked: Qt's applicationDirPath() +# reads /proc/self/exe which dereferences symlinks, so a symlinked host +# would resolve to the build/ dir and the supervisor would look for +# frankenphp + symfony there instead of in the staged layout. Real +# AppImages copy the binary, mimicking that here. +cp "$HOST_BIN" "$ROOT/usr/bin/$APP_NAME" +ln -s "$(command -v frankenphp)" "$ROOT/usr/bin/frankenphp" +cp -a "$STAGING/." "$ROOT/usr/share/$APP_NAME/symfony/" +cp "$CADDYFILE" "$ROOT/usr/share/$APP_NAME/Caddyfile" + +# Make the staged Symfony tree read-only so the cache/log redirect is +# actually exercised — without the Kernel::getCacheDir/getLogDir override, +# Symfony tries to mkdir var/cache here and fails. +chmod -R a-w "$ROOT/usr/share/$APP_NAME/symfony" + +# ── Launch the host ──────────────────────────────────────────────────── +step "launch host (bundled mode, offscreen, isolated XDG dirs)" +LOG="$DATA_DIR/host.log" +env -u BRIDGE_URL \ + XDG_DATA_HOME="$DATA_DIR/share" \ + XDG_CACHE_HOME="$DATA_DIR/cache" \ + XDG_CONFIG_HOME="$DATA_DIR/config" \ + QT_QPA_PLATFORM=offscreen \ + "$ROOT/usr/bin/$APP_NAME" > "$LOG" 2>&1 & +PID=$! + +# ── Poll /healthz ────────────────────────────────────────────────────── +step "wait for /healthz" +DEADLINE=$(( $(date +%s) + 30 )) +HEALTHZ_BODY="" +while [ "$(date +%s)" -lt "$DEADLINE" ]; do + if ! kill -0 "$PID" 2>/dev/null; then + sed 's/^/ /' "$LOG" >&2 || true + fail "host died during boot" + fi + if HEALTHZ_BODY="$(curl -fsS -m 1 "http://127.0.0.1:$PORT/healthz" 2>/dev/null)"; then + break + fi + sleep 0.2 +done +[ -n "$HEALTHZ_BODY" ] || { sed 's/^/ /' "$LOG" >&2 || true; fail "/healthz never responded within 30s"; } + +# ── Verify bundle deep-load ──────────────────────────────────────────── +step "/healthz body: $HEALTHZ_BODY" +echo "$HEALTHZ_BODY" | grep -q '"status":"ok"' \ + || fail "/healthz didn't return status:ok" +echo "$HEALTHZ_BODY" | grep -q '"bundle":"PhpQml\\\\Bridge\\\\Publisher"' \ + || fail "/healthz missing bundle field — HealthController deep-load broken" + +# ── Verify the cache/log redirect actually fired ─────────────────────── +step "verify Symfony wrote cache to user data dir, not the read-only staging" +# Qt's QStandardPaths::AppDataLocation on Linux is $XDG_DATA_HOME//, +# org="php-qml" comes from main.cpp setOrganizationName, app="todo" from setApplicationName. +USER_DATA="$DATA_DIR/share/php-qml/$APP_NAME" +[ -d "$USER_DATA/var/cache" ] \ + || fail "user-data var/cache missing at $USER_DATA — APP_CACHE_DIR override didn't fire" +# And not into the staged tree (which is chmod -w anyway): +if [ -d "$ROOT/usr/share/$APP_NAME/symfony/var/cache/prod" ] && \ + [ "$(ls -A "$ROOT/usr/share/$APP_NAME/symfony/var/cache/prod" 2>/dev/null)" ]; then + fail "Symfony wrote into the read-only staging tree — Kernel::getCacheDir override broken" +fi + +step "All bundled-supervisor assertions passed."