- 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.
260 lines
9.3 KiB
Bash
Executable File
260 lines
9.3 KiB
Bash
Executable File
#!/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 "$@"
|