Files
Jannis Braun 2b0f93ec62 polish(desktop): rename userData folder to Backspace with first-launch migration
Electron derived userData from package.json's `@backspace/desktop` name, leaking
the monorepo's pnpm scope into ~/Library/Application Support/. Now `app.setName`
runs at module load before any userData consumer, and a one-shot migration
atomically moves the historical folder to <appData>/Backspace, cleaning the
empty @backspace/ parent. Conservative on conflict — never clobbers an existing
populated target. EXDEV fallback to recursive copy. Smoke-recovery path flipped
back to Backspace.
2026-05-03 14:36:34 +02:00

344 lines
12 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.
#
# Automated scenarios (this script):
# 2 bad URL → load-failed recovery
# 4 boot stall (static page that never pings) → renderer-stalled recovery
# 13 positive control: rendererReady ping disarms boot timer (no recovery)
#
# Manual scenarios (run yourself in front of the UI — see spec §9):
# 1 network failure (needs sudo to toggle Wi-Fi)
# 3 renderer crash via DevTools (process.crash())
# 5,6 native notification visible/not-visible (OS-level rendering)
# 7 tray menu interaction
# 8,9 recovery button clicks (Reload success / re-fail)
# 10 corrupt recovery.html (requires asar manipulation)
# 11 force-kill + recovery + Restart-to-Install (verify electron-updater behavior)
# 12 hidden autostart + boot failure
# 14 SPA navigation (logged-in session, channel switches don't trip timer)
# 15 real Windows-incident reproduction (build N, edit web bundle, run Vite)
#
# Strategy notes:
# - Scenarios 4 and 13 use a local Python http.server serving a tiny static page,
# pointed at via BACKSPACE_URL. This bypasses the need to build a stalled web
# bundle: scenario 4 omits the rendererReady call; scenario 13 includes it.
# - Scenario 2 writes a bad URL to instance-url.json and lets the normal load
# path fail (did-fail-load → load-failed recovery).
# - All scenarios grep stderr for [recovery] entered: ... lines emitted by
# enterRecoveryMode in recovery.ts.
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
}
# Returns 0 if the regex is NOT present in the log file (negative assertion),
# 1 if it is present.
assert_no_log() {
local logfile="$1"
local pattern="$2"
if grep -Eq "$pattern" "$logfile" 2>/dev/null; then
return 1
fi
return 0
}
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 → recovery (renderer-stalled)"
log "Strategy: serve a tiny static HTML page locally that has no window.backspace"
log " integration → renderer loads but never pings → 20s → boot timer fires."
# The desktop loads remote URLs via mainWindow.loadURL. Setting BACKSPACE_URL
# at launch overrides the saved instance URL. We serve a minimal HTML page
# that doesn't import the web bundle at all — guaranteed to never call
# rendererReady(). No web/desktop rebuilds needed.
local stall_dir="$TMP_DIR/stall-page"
mkdir -p "$stall_dir"
cat > "$stall_dir/index.html" <<'HTML'
<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title>Boot Stall Test</title></head>
<body style="background:#13131a;color:#efefef;font-family:sans-serif;padding:2rem;">
<h1>Boot stall test page</h1>
<p>Renderer is alive but never calls window.backspace.rendererReady().
The main-process boot timer should fire after 20s and trigger recovery.</p>
</body>
</html>
HTML
# Start a local HTTP server on a random high port to avoid collisions.
local port=8765
python3 -m http.server "$port" --bind 127.0.0.1 -d "$stall_dir" > "$TMP_DIR/httpd.log" 2>&1 &
local httpd_pid=$!
# Poll for readiness (up to 5s).
local ready=0
for _ in 1 2 3 4 5 6 7 8 9 10; do
if curl -sf "http://127.0.0.1:$port/" > /dev/null 2>&1; then
ready=1; break
fi
sleep 0.5
done
if [[ "$ready" != "1" ]]; then
bad "Scenario 4: local HTTP server failed to start on port $port"
kill "$httpd_pid" 2>/dev/null || true
return
fi
local logfile="$TMP_DIR/scenario4.log"
BACKSPACE_URL="http://127.0.0.1:$port/" "$APP_BINARY" > "$logfile" 2>&1 &
local pid=$!
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"
kill "$httpd_pid" 2>/dev/null || true
# Wait for httpd to actually exit so the port is free for re-runs.
wait "$httpd_pid" 2>/dev/null || true
}
scenario_13_ping_disarms_timer() {
log "Scenario 13 (positive control): rendererReady ping disarms the boot timer"
log "Strategy: serve a page that DOES call window.backspace.rendererReady() →"
log " verify NO recovery entry within 25s (timer correctly disarmed)."
local ready_dir="$TMP_DIR/ready-page"
mkdir -p "$ready_dir"
cat > "$ready_dir/index.html" <<'HTML'
<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title>Boot Ready Test</title></head>
<body style="background:#13131a;color:#efefef;font-family:sans-serif;padding:2rem;">
<h1>Boot ready test page</h1>
<p>Renderer calls window.backspace.rendererReady() — the main-process
boot timer should be disarmed; recovery must NOT enter.</p>
<script>
// Marker fetches let the smoke harness verify each branch via the http.server log.
fetch('/_marker_script_ran').catch(function(){});
function tryPing() {
if (typeof window.backspace !== 'undefined' &&
typeof window.backspace.rendererReady === 'function') {
window.backspace.rendererReady();
fetch('/_marker_ping_sent').catch(function(){});
return true;
}
return false;
}
if (!tryPing()) {
// Preload may attach window.backspace asynchronously in some configs;
// retry briefly before declaring it absent.
var attempts = 0;
var iv = setInterval(function() {
attempts++;
if (tryPing() || attempts > 20) {
clearInterval(iv);
if (attempts > 20) fetch('/_marker_no_bridge').catch(function(){});
}
}, 100);
}
</script>
</body>
</html>
HTML
local port=8766
python3 -m http.server "$port" --bind 127.0.0.1 -d "$ready_dir" > "$TMP_DIR/httpd13.log" 2>&1 &
local httpd_pid=$!
local ready=0
for _ in 1 2 3 4 5 6 7 8 9 10; do
if curl -sf "http://127.0.0.1:$port/" > /dev/null 2>&1; then
ready=1; break
fi
sleep 0.5
done
if [[ "$ready" != "1" ]]; then
bad "Scenario 13: local HTTP server failed to start on port $port"
kill "$httpd_pid" 2>/dev/null || true
return
fi
local logfile="$TMP_DIR/scenario13.log"
BACKSPACE_URL="http://127.0.0.1:$port/" "$APP_BINARY" > "$logfile" 2>&1 &
local pid=$!
log " Waiting 25s — boot timer must NOT fire (negative assertion)..."
sleep 25
if assert_no_log "$logfile" '\[recovery\] entered:'; then
ok "Scenario 13: boot timer correctly disarmed by rendererReady ping"
else
bad "Scenario 13: recovery entered despite rendererReady ping (boot timer not disarmed)"
log "Diagnostic — what the page script did (from httpd.log):"
grep -E 'GET /_marker_' "$TMP_DIR/httpd13.log" 2>/dev/null || log " (no markers — script did not run at all)"
log "Last 20 lines of app log:"
tail -n 20 "$logfile" || true
fi
kill_app "$pid"
kill "$httpd_pid" 2>/dev/null || true
wait "$httpd_pid" 2>/dev/null || true
}
# ─── 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
scenario_13_ping_disarms_timer
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 "$@"