diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx index 65b1faab..b30b49c3 100644 --- a/packages/web/src/App.tsx +++ b/packages/web/src/App.tsx @@ -34,7 +34,12 @@ export function App() { // first commit; main owns "armed once" gating so duplicate calls (e.g. // ErrorBoundary path) are idempotent. Semantic: "renderer survived // render," NOT "data loaded." A spinner on screen counts as boot success. + // + // Gated on VITE_FORCE_BOOT_STALL so the smoke-test harness can build a + // variant that intentionally never pings, exercising the renderer-stalled + // recovery path without hand-editing source. Set 'true' / '1' to force stall. useEffect(() => { + if (import.meta.env.VITE_FORCE_BOOT_STALL) return; if (typeof window.backspace?.rendererReady === 'function') { window.backspace.rendererReady(); } diff --git a/packages/web/src/main.tsx b/packages/web/src/main.tsx index d0c0e7fb..8647ad09 100644 --- a/packages/web/src/main.tsx +++ b/packages/web/src/main.tsx @@ -23,6 +23,9 @@ class ErrorBoundary extends React.Component< // Renderer is alive enough to show the fallback UI — disarm the boot timer. // Without this, the timer fires 20s after a caught render error and // overrides the in-app error UI with native recovery, which is wrong. + // Gated on VITE_FORCE_BOOT_STALL so the smoke harness can suppress both + // ping paths simultaneously when testing the renderer-stalled recovery path. + if (import.meta.env.VITE_FORCE_BOOT_STALL) return; if (typeof window.backspace?.rendererReady === 'function') { window.backspace.rendererReady(); } diff --git a/packages/web/src/vite-env.d.ts b/packages/web/src/vite-env.d.ts index 4218a8ae..a84033a1 100644 --- a/packages/web/src/vite-env.d.ts +++ b/packages/web/src/vite-env.d.ts @@ -1,5 +1,12 @@ /// +interface ImportMetaEnv { + readonly VITE_FORCE_BOOT_STALL?: string; +} +interface ImportMeta { + readonly env: ImportMetaEnv; +} + declare module '@sapphi-red/web-noise-suppressor/rnnoiseWorklet.js?url' { const url: string; export default url; diff --git a/scripts/smoke-recovery.sh b/scripts/smoke-recovery.sh new file mode 100755 index 00000000..96d79e08 --- /dev/null +++ b/scripts/smoke-recovery.sh @@ -0,0 +1,259 @@ +#!/usr/bin/env bash +# scripts/smoke-recovery.sh +# Automated smoke tests for the Electron Recovery Mode subsystem. +# Covers scenarios that don't require GUI interaction or sudo. +# +# Manual scenarios (run separately, see docs/superpowers/specs/...): +# 1 network failure (needs sudo to toggle Wi-Fi) +# 3 renderer crash via DevTools +# 5,6 native notification visibility +# 7 tray menu interaction +# 8,9 recovery button clicks +# 10 corrupt recovery.html (requires asar manipulation) +# 11 force-kill recovery +# 12 hidden autostart +# 14 SPA navigation +# 15 real Windows-incident reproduction (needs Vite running) +# +# Automated scenarios: +# 2 bad URL → load-failed +# 4 boot stall (VITE_FORCE_BOOT_STALL build) → renderer-stalled +# 13 ErrorBoundary path doesn't trigger boot timer (negative test — see note below) +# +# Note on scenario 13: The VITE_FORCE_BOOT_STALL gate suppresses the ErrorBoundary +# ping as well, so an isolated scenario-13 negative test (ErrorBoundary DOES ping +# in normal builds) requires a separate non-stalled build run. This script covers +# the stalled-build scenario; the positive scenario-13 case is tested manually. + +set -euo pipefail + +# ─── Config ────────────────────────────────────────────────────────────── +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +APP_BINARY="$REPO_ROOT/packages/desktop/dist-electron/mac-arm64/Backspace.app/Contents/MacOS/Backspace" +USER_DATA_DIR="$HOME/Library/Application Support/Backspace" +INSTANCE_URL_FILE="$USER_DATA_DIR/instance-url.json" +INSTANCE_URL_BACKUP="$INSTANCE_URL_FILE.smoketest-backup" +TMP_DIR="$(mktemp -d -t backspace-smoke.XXXXXX)" +trap 'cleanup' EXIT INT TERM + +SKIP_BUILD=0 +[[ "${1:-}" == "--skip-build" ]] && SKIP_BUILD=1 + +# ─── Utilities ────────────────────────────────────────────────────────── +declare -i PASS=0 +declare -i FAIL=0 +declare -a RESULTS=() + +log() { printf '\n\033[1;36m[smoke]\033[0m %s\n' "$*"; } +ok() { printf ' \033[1;32m✓\033[0m %s\n' "$*"; PASS=$((PASS+1)); RESULTS+=("PASS: $*"); } +bad() { printf ' \033[1;31m✗\033[0m %s\n' "$*"; FAIL=$((FAIL+1)); RESULTS+=("FAIL: $*"); } + +cleanup() { + # Always restore the user's instance-url.json if we backed it up + if [[ -f "$INSTANCE_URL_BACKUP" ]]; then + mv "$INSTANCE_URL_BACKUP" "$INSTANCE_URL_FILE" + fi + # Kill any leftover Backspace processes started by this script + pkill -f "$APP_BINARY" 2>/dev/null || true + # Clean tmp + rm -rf "$TMP_DIR" +} + +backup_instance_url() { + if [[ -f "$INSTANCE_URL_FILE" ]]; then + cp "$INSTANCE_URL_FILE" "$INSTANCE_URL_BACKUP" + fi +} + +write_bad_instance_url() { + mkdir -p "$USER_DATA_DIR" + printf '{"url":"https://nonexistent-instance.invalid"}' > "$INSTANCE_URL_FILE" +} + +# Launches the binary in the background, captures stdout+stderr to the named +# log file, and echoes the PID. Caller is responsible for killing it. +launch() { + local logfile="$1" + shift + "$APP_BINARY" "$@" > "$logfile" 2>&1 & + echo $! +} + +# Waits up to N seconds for a regex to appear in the log file. +# Returns 0 if found, 1 if timeout. +wait_for_log() { + local logfile="$1" + local pattern="$2" + local timeout_s="$3" + local start + start=$(date +%s) + while true; do + if grep -Eq "$pattern" "$logfile" 2>/dev/null; then + return 0 + fi + local now + now=$(date +%s) + if (( now - start >= timeout_s )); then + return 1 + fi + sleep 0.5 + done +} + +kill_app() { + local pid="$1" + kill "$pid" 2>/dev/null || true + # Wait for clean shutdown (up to 2.5s), then force-kill + local i + for i in 1 2 3 4 5; do + kill -0 "$pid" 2>/dev/null || return 0 + sleep 0.5 + done + kill -9 "$pid" 2>/dev/null || true +} + +# ─── Scenario helpers ────────────────────────────────────────────────── +scenario_2_bad_url() { + log "Scenario 2: Bad instance URL → recovery (load-failed)" + backup_instance_url + write_bad_instance_url + + local logfile="$TMP_DIR/scenario2.log" + local pid + pid=$(launch "$logfile") + + if wait_for_log "$logfile" '\[recovery\] entered: load-failed' 15; then + ok "Scenario 2: load-failed recovery entered" + else + bad "Scenario 2: load-failed recovery did NOT enter within 15s" + log "Last 30 lines of log:" + tail -n 30 "$logfile" || true + fi + + kill_app "$pid" + + # Restore (cleanup trap covers the EXIT case too, but be explicit here + # so the subsequent scenario starts with a known-good state) + if [[ -f "$INSTANCE_URL_BACKUP" ]]; then + mv "$INSTANCE_URL_BACKUP" "$INSTANCE_URL_FILE" + else + rm -f "$INSTANCE_URL_FILE" + fi +} + +scenario_4_boot_stall() { + log "Scenario 4: Boot stall (VITE_FORCE_BOOT_STALL build) → recovery (renderer-stalled)" + log "Building stall-variant web bundle..." + + if (cd "$REPO_ROOT" && VITE_FORCE_BOOT_STALL=1 pnpm --filter @backspace/web build > "$TMP_DIR/web-build.log" 2>&1); then + log " web build complete" + else + bad "Scenario 4: VITE_FORCE_BOOT_STALL web build failed (see $TMP_DIR/web-build.log)" + return + fi + + log "Rebuilding desktop app with stalled bundle..." + if (cd "$REPO_ROOT" && pnpm --filter @backspace/desktop build > "$TMP_DIR/desktop-build.log" 2>&1); then + log " desktop build complete" + else + bad "Scenario 4: desktop rebuild failed (see $TMP_DIR/desktop-build.log)" + # Still rebuild the normal web bundle before returning + log "Rebuilding web bundle without VITE_FORCE_BOOT_STALL (cleanup)..." + (cd "$REPO_ROOT" && pnpm --filter @backspace/web build > "$TMP_DIR/web-rebuild-cleanup.log" 2>&1) || true + (cd "$REPO_ROOT" && pnpm --filter @backspace/desktop build > "$TMP_DIR/desktop-rebuild-cleanup.log" 2>&1) || true + return + fi + + # Use the user's saved URL so we get past did-fail-load and into the + # actual boot-stall path. If none is saved, the scenario can't run. + backup_instance_url + if [[ -f "$INSTANCE_URL_BACKUP" ]]; then + cp "$INSTANCE_URL_BACKUP" "$INSTANCE_URL_FILE" + else + log " No saved instance URL found — Scenario 4 needs a working URL to exercise" + log " the boot stall path. Run the app once, pick an instance, then re-run." + bad "Scenario 4: no saved URL to test against" + # Rebuild normal bundle before returning + log "Rebuilding web bundle without VITE_FORCE_BOOT_STALL (cleanup)..." + (cd "$REPO_ROOT" && pnpm --filter @backspace/web build > "$TMP_DIR/web-rebuild-cleanup.log" 2>&1) || true + (cd "$REPO_ROOT" && pnpm --filter @backspace/desktop build > "$TMP_DIR/desktop-rebuild-cleanup.log" 2>&1) || true + return + fi + + local logfile="$TMP_DIR/scenario4.log" + local pid + pid=$(launch "$logfile") + + log " Waiting up to 25s for renderer-stalled timer to fire..." + if wait_for_log "$logfile" '\[recovery\] entered: renderer-stalled' 25; then + ok "Scenario 4: renderer-stalled recovery entered" + else + bad "Scenario 4: renderer-stalled recovery did NOT enter within 25s" + log "Last 30 lines of log:" + tail -n 30 "$logfile" || true + fi + + kill_app "$pid" + + # Restore the user's instance-url.json + if [[ -f "$INSTANCE_URL_BACKUP" ]]; then + mv "$INSTANCE_URL_BACKUP" "$INSTANCE_URL_FILE" + else + rm -f "$INSTANCE_URL_FILE" + fi + + # IMPORTANT: rebuild both bundles WITHOUT the env var so subsequent dev/test + # runs are not left permanently stalled. + log "Rebuilding web bundle without VITE_FORCE_BOOT_STALL (restoring normal build)..." + if (cd "$REPO_ROOT" && pnpm --filter @backspace/web build > "$TMP_DIR/web-rebuild.log" 2>&1); then + log " web bundle restored" + else + log " WARNING: web rebuild failed — run 'pnpm --filter @backspace/web build' manually" + fi + if (cd "$REPO_ROOT" && pnpm --filter @backspace/desktop build > "$TMP_DIR/desktop-rebuild.log" 2>&1); then + log " desktop build restored" + else + log " WARNING: desktop rebuild failed — run 'pnpm --filter @backspace/desktop build' manually" + fi +} + +# ─── Main ─────────────────────────────────────────────────────────────── +main() { + log "Backspace Recovery Mode — Automated Smoke Tests" + log "Repo: $REPO_ROOT" + log "Tmp: $TMP_DIR" + + if [[ "$SKIP_BUILD" == "0" ]]; then + log "Building desktop app (pass --skip-build to reuse existing)..." + if ! (cd "$REPO_ROOT" && pnpm --filter @backspace/desktop build > "$TMP_DIR/initial-build.log" 2>&1); then + bad "Initial desktop build failed (see $TMP_DIR/initial-build.log)" + return 1 + fi + fi + + if [[ ! -x "$APP_BINARY" ]]; then + bad "App binary not found at $APP_BINARY" + log "Run 'pnpm --filter @backspace/desktop build' first." + return 1 + fi + + scenario_2_bad_url + scenario_4_boot_stall + + log "─── Summary ─────────────────────────────────────────────────────" + local r + for r in "${RESULTS[@]}"; do + if [[ "$r" == PASS:* ]]; then + printf ' \033[1;32m✓\033[0m %s\n' "${r#PASS: }" + else + printf ' \033[1;31m✗\033[0m %s\n' "${r#FAIL: }" + fi + done + log "$PASS passed, $FAIL failed" + + if (( FAIL > 0 )); then + return 1 + fi +} + +main "$@"