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:
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Vendored
+7
@@ -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;
|
||||
|
||||
Executable
+259
@@ -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 "$@"
|
||||
Reference in New Issue
Block a user