#!/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 # ── Second launch: same XDG_DATA_HOME, fresh staging mount ───────────── # Real AppImages get a fresh /tmp/.mount_ per launch but reuse the # user data dir, so any cached absolute path from launch N is stale by N+1. # Tear down the running host, re-run from a NEW staging dir (mimicking the # fresh-mount situation), assert /healthz comes back up. step "tear down + relaunch from fresh staging (regression: cache-baked-mount-path)" kill -TERM "$PID" 2>/dev/null || true # 3s grace: teardownChild itself waits up to 2s for frankenphp to finish # after sending it SIGTERM, so the host can take ~2.x seconds to exit # cleanly. A 2s loop here was right at the boundary and triggered the # fallback SIGKILL on slower runners. for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15; do kill -0 "$PID" 2>/dev/null || break sleep 0.2 done kill -KILL "$PID" 2>/dev/null || true PID="" chmod -R u+w "$ROOT/usr/share/$APP_NAME/symfony" 2>/dev/null rm -rf "$ROOT" ROOT="$(mktemp -d)" mkdir -p "$ROOT/usr/bin" "$ROOT/usr/share/$APP_NAME" 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" chmod -R a-w "$ROOT/usr/share/$APP_NAME/symfony" LOG2="$DATA_DIR/host2.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" > "$LOG2" 2>&1 & PID=$! DEADLINE=$(( $(date +%s) + 30 )) HEALTHZ2_BODY="" while [ "$(date +%s)" -lt "$DEADLINE" ]; do if ! kill -0 "$PID" 2>/dev/null; then sed 's/^/ /' "$LOG2" >&2 || true fail "host died during 2nd boot" fi if HEALTHZ2_BODY="$(curl -fsS -m 1 "http://127.0.0.1:$PORT/healthz" 2>/dev/null)"; then break fi sleep 0.2 done [ -n "$HEALTHZ2_BODY" ] || { sed 's/^/ /' "$LOG2" >&2 || true; fail "/healthz never responded on 2nd launch — stale cache?"; } echo "$HEALTHZ2_BODY" | grep -q '"status":"ok"' \ || fail "2nd-launch /healthz didn't return status:ok" # ── Clean shutdown: SIGTERM the host, assert no Qt warning + no orphan frankenphp ── step "graceful shutdown — assert the supervisor kills its frankenphp child" SHUTDOWN_PID="$PID" # Capture every descendant PID before killing, so we can verify they all exit. DESCENDANTS="$(pgrep -P "$SHUTDOWN_PID" || true)" kill -TERM "$SHUTDOWN_PID" 2>/dev/null || true for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15; do kill -0 "$SHUTDOWN_PID" 2>/dev/null || break sleep 0.2 done if kill -0 "$SHUTDOWN_PID" 2>/dev/null; then kill -KILL "$SHUTDOWN_PID" 2>/dev/null || true fail "host didn't exit within 3s of SIGTERM" fi PID="" # Qt warning means QProcess was destroyed before the child exited. if grep -q "QProcess: Destroyed while process .* is still running" "$LOG2"; then sed 's/^/ /' "$LOG2" >&2 fail "host exited but logged QProcess-destroyed-while-running warning" fi # Any descendant still alive = orphan; the supervisor's teardown didn't wait. for d in $DESCENDANTS; do if kill -0 "$d" 2>/dev/null; then # Be specific: only frankenphp orphans matter (QtNetwork might leave # short-lived helper threads but those exit on their own). if ps -p "$d" -o comm= 2>/dev/null | grep -q frankenphp; then kill -KILL "$d" 2>/dev/null || true fail "frankenphp child PID $d outlived the host (supervisor didn't clean up)" fi fi done step "All bundled-supervisor assertions passed (incl. 2nd-launch cache wipe + clean shutdown)."