feat(testing): smoke-test script + VITE_FORCE_BOOT_STALL gate

- App.tsx and main.tsx ErrorBoundary gate rendererReady() on
  VITE_FORCE_BOOT_STALL build env var. When set, the ping is suppressed
  so the main-process boot timer fires (exercising the renderer-stalled
  recovery path without hand-editing source).
- vite-env.d.ts declares the env var type so TS doesn't complain.
- scripts/smoke-recovery.sh automates scenarios 2 (bad URL → load-failed)
  and 4 (forced stall → renderer-stalled), grepping stderr for the
  [recovery] entered: ... lines added by the prior commit. Backs up and
  restores the user's instance-url.json. Manual scenarios documented in
  the script header.
This commit is contained in:
Jannis Braun
2026-05-03 13:21:54 +02:00
parent fff39f8d76
commit 7e30db3773
4 changed files with 274 additions and 0 deletions
+5
View File
@@ -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();
}
+3
View File
@@ -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();
}
+7
View File
@@ -1,5 +1,12 @@
/// <reference types="vite/client" />
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;
+259
View File
@@ -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 "$@"