Files
VoiceStudio/scripts/smoke-test.sh
T
Palash DebnathandClaude Opus 4.7 a1ef66c321 Stability pass: DB leaks, App.jsx hooks refactor, desktop bootstrap (#49)
* fix: eliminate DB connection leaks, race conditions, and deprecated asyncio API

## DB Connection Leaks (P0)
- Convert 38 raw get_db() calls to db_conn() context manager across 14 router files
- Connections are now guaranteed to close even when exceptions are raised
- profiles.py create_profile: clean up orphaned audio file if DB insert fails
- profiles.py lock_profile: consolidate 3 separate conn.close() error paths

## Race Condition (P1)
- Add _dub_jobs_lock (threading.Lock) to protect _dub_jobs dict in dub_pipeline.py
- get_job/put_job now thread-safe for concurrent dub sessions

## asyncio Deprecation (P2)
- Replace 23 asyncio.get_event_loop() calls with asyncio.get_running_loop()
- Prevents DeprecationWarning on Python 3.12+ and future breakage on 3.14

## Quick Fixes
- gallery.py preview_voice: remove filesystem path from error response (P2)
- dub_pipeline.py parse_vtt_segments: remove redundant `import re` inside loop (P3)
- gallery.py _init_gallery_db: use db_conn() context manager (P2)

* refactor: extract hooks, centralize isTauri, add pytest-cov

## Frontend
- Extract useTTS hook (150 LOC) — TTS generation, streaming, audio ingestion
- Extract useProfiles hook (219 LOC) — voice profile CRUD, lock/unlock, preview
- Centralize isTauri detection: dialog.js, VoiceGallery.jsx, Settings.jsx
  now import from utils/media.js instead of 4 different detection patterns

## Backend
- Add pytest-cov to dev dependencies
- Baseline coverage: 39% across backend/ (214 tests pass)
- Add .coverage to .gitignore

* feat: add Vitest + checkJs, extract useDubWorkflow + useAppData hooks

## Frontend Testing (new)
- Set up Vitest with jsdom environment + @testing-library/react
- 11 tests: utils (isTauri, formatTime, constants) + Zustand store (mode, text, dubStep, pill)
- Scripts: 'test' (vitest run), 'test:watch' (vitest), 'test:legacy' (node runner)

## App.jsx Decomposition (continued)
- Extract useDubWorkflow hook (387 LOC) — upload, ingest, transcribe SSE,
  translate, generate SSE, abort, stop, cleanup
- Extract useAppData hook (181 LOC) — data loading, localStorage persistence,
  WebSocket real-time updates, model-status pill management

## TypeScript checkJs
- Enable checkJs: true in tsconfig.json for IDE-level type checking
- 947 existing errors (informational, not blocking builds)
- noImplicitAny remains false to avoid blocking

* ci: add Vitest step, fix useProfiles duplicate state

## CI
- Add 'Run Vitest (frontend)' step — runs 11 unit tests
- Override --checkJs false in CI typecheck to avoid 947 pre-existing errors
- Rename legacy test step for clarity

## Hooks
- Fix useProfiles to accept loadProfiles from parent (useAppData)
  instead of managing its own duplicate profiles array

* refactor: wire hooks into App.jsx — 2067 → 1129 LOC (-45%)

App.jsx now delegates to extracted hooks instead of inline logic:
- useAppData: data loading, localStorage, WebSocket, model pill
- useProfiles: voice profile CRUD, lock/unlock, preview
- useTTS: generation, streaming, audio ingestion
- useDubWorkflow: upload, transcribe SSE, translate, generate SSE

988 lines removed. All handler logic lives in focused,
independently testable hooks. Store selectors and render
JSX stay in App.jsx as the shell.

Verified: vite build clean, 11 frontend + 214 backend tests pass.

* feat: show real-time percentage on model loading pill

Backend: register hf_progress listener during _load_model_sync()
so download/weight-loading tqdm events update _loading_detail with
a progress percentage (0-99%). get_model_status() now includes a
'progress' field that the frontend polls.

Frontend: useAppData reads msQuery.data.progress and calls
setPillProgress() — the FloatingPill already renders the percentage
text and progress bar width from this value.

* fix: prevent FileNotFoundError in desktop bundle during model init

transformers >=4.52 calls _can_set_experts_implementation() and
_can_set_attn_implementation() during PreTrainedModel.__init__,
which open the class source file via open(class_file). In a Tauri
desktop bundle, module.__file__ points to a path that doesn't
exist on disk, causing:

  FileNotFoundError: .../omnivoice/models/omnivoice.py

Override both classmethods on OmniVoice to return static values
without filesystem access. OmniVoice doesn't use MoE experts
(return False), but does support flex/flash attn (return True).

* fix: sync source dirs on every bootstrap, not just first run

The Tauri bootstrap previously only copied omnivoice/ and backend/
to Application Support on the first run. Subsequent app updates
kept using stale source files, preventing bug fixes from landing.

Now ensure_venv_ready() always syncs both directories from the
bundle resources before returning, even when the venv is healthy.
This fixes the FileNotFoundError crash where the old omnivoice.py
lacked the _can_set_experts_implementation override.

* ui: premium setup wizard polish

- Primary button: solid gradient fill with hover glow + lift + press
- Stepper nav: connected pills with glow ring on active step
- Welcome cards: glassmorphism with stagger-in animations, lucide icons,
  left-border accent strip, hover translate
- Preflight panel: colored icon pill backgrounds, stagger-slide entrance
- Step transitions: fade+slide animation via keyed wrapper
- Footnote: shortened paths (~/ notation), Reveal in Finder button
- Recommendation banner: gradient background with accent glow
- Compact spacing throughout for denser, professional layout

* fix: kill zombie backend on clean+retry bootstrap

When clean_and_retry_bootstrap removes the project dir, any old
uvicorn process still running from the deleted paths remains alive
on port 3900. The subsequent retry_bootstrap sees the port is
healthy and attaches to the zombie instead of re-bootstrapping.

Now explicitly kill any process on the backend port after cleaning,
before calling retry_bootstrap.

* feat: integrate speaker clones into dubbing interface, sanitize system environment variables for subprocesses, and improve FFMPEG binary path resolution.

* fix: restore docker compose default + drop dead setSeed call

- deploy/docker-compose.yml: remove profiles: ["cpu"] from the default
  service so `docker compose up` matches the comment on line 5. With the
  profile present, no service auto-started.

- frontend/src/App.jsx: drop the setSeed call in restoreHistory. The
  selector was never reintroduced after the App.jsx hooks split, and
  there is no seed state in the store — seeds are generated fresh per
  call in useTTS and only read from history items for display.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: address CodeRabbit review — async detection, dub stream, bootstrap fail-fast

- backend/services/tts_backend.py: invert async-context detection in
  _ensure_loaded. The previous code unconditionally caught its own
  diagnostic RuntimeError and then called asyncio.run() inside a
  running loop, masking the intended error message.

- frontend/src/hooks/useDubWorkflow.js: require a terminal `done` event
  before reporting dub success. Without this, a dropped stream after
  partial progress would flip the UI to `done`, refresh history, and
  play the completion ping as if generation finished.

- frontend/src/hooks/useDubWorkflow.js: restore the previous step when
  tasksCancel() fails. The UI was getting stuck in `stopping` forever
  on cancel errors.

- frontend/src-tauri/src/bootstrap.rs: fail-fast when source sync fails
  after the existing directory has already been removed. The previous
  warn-and-continue path could leave the install with no backend/ or
  omnivoice/ sources and defer the failure to backend startup with a
  cryptic error.

- backend/api/routers/generation.py: add `from e` to the ValueError →
  HTTPException re-raise (Ruff B904).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: preserve % suffix in TTS generation timer

The 100ms timer in useTTS was rewriting generationTime to a plain
elapsed-seconds string, which immediately wiped the "(xx%)" download
suffix written on the next iteration of the response-body loop. The
real-time percentage was flickering on/off as a result.

Read the previous value inside the setter and reattach any existing
percent suffix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 21:49:04 +05:30

389 lines
14 KiB
Bash
Executable File

#!/usr/bin/env bash
# ──────────────────────────────────────────────────────────────────────────
# smoke-test.sh — Automated end-to-end first-launch verification
#
# Simulates a REAL end-user fresh install:
# 1. Wipes all app data (venv, config, tools, logs, HF cache)
# 2. Builds the debug production bundle
# 3. Launches the app in the background
# 4. Polls the backend until it's healthy or timeout
# 5. Runs health checks against every critical endpoint
# 6. Checks device detection, model status, region config
# 7. Kills the app and reports pass/fail
#
# This is what you should run BEFORE every release. It catches:
# - Bootstrap failures (missing deps, bad downloads)
# - GPU detection regressions
# - FFmpeg/ffprobe resolution failures
# - Region mirror misconfig
# - Model loading crashes
#
# Usage:
# bun run smoke-test # full wipe + build + test
# bun run smoke-test:quick # skip build, re-test last binary
# bun run smoke-test:upgrade # keep data, test upgrade path
# ──────────────────────────────────────────────────────────────────────────
set -euo pipefail
APP_ID="com.debpalash.omnivoice-studio"
TAURI_DIR="frontend/src-tauri"
APP_NAME="OmniVoice Studio"
BACKEND_URL="http://127.0.0.1:3900"
# Timeouts (seconds)
BOOTSTRAP_TIMEOUT=600 # 10 min for full venv bootstrap
HEALTH_TIMEOUT=120 # 2 min for backend to become healthy after bootstrap
MODEL_TIMEOUT=180 # 3 min for model to load
# ── Colors ─────────────────────────────────────────────────────────────────
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
BOLD='\033[1m'
NC='\033[0m' # No Color
pass() { echo -e " ${GREEN}✓${NC} $1"; }
fail() { echo -e " ${RED}✗${NC} $1"; FAILURES=$((FAILURES + 1)); }
warn() { echo -e " ${YELLOW}⚠${NC} $1"; }
info() { echo -e " ${CYAN}→${NC} $1"; }
header() { echo -e "\n${BOLD}$1${NC}"; }
FAILURES=0
TESTS=0
APP_PID=""
# ── Cleanup on exit ────────────────────────────────────────────────────────
cleanup() {
if [ -n "$APP_PID" ] && kill -0 "$APP_PID" 2>/dev/null; then
info "Killing app (pid $APP_PID)..."
kill "$APP_PID" 2>/dev/null || true
sleep 2
kill -9 "$APP_PID" 2>/dev/null || true
fi
# Also kill any orphaned backend
pkill -f "uvicorn.*3900" 2>/dev/null || true
}
trap cleanup EXIT
# ── Detect platform ───────────────────────────────────────────────────────
OS="$(uname -s)"
case "$OS" in
Darwin) PLATFORM="macos" ;;
Linux) PLATFORM="linux" ;;
*) echo "❌ Unsupported platform: $OS"; exit 1 ;;
esac
if [ "$PLATFORM" = "macos" ]; then
APP_DATA="$HOME/Library/Application Support/${APP_ID}"
OV_DATA="$HOME/Library/Application Support/OmniVoice"
else
APP_DATA="${XDG_DATA_HOME:-$HOME/.local/share}/${APP_ID}"
OV_DATA="${XDG_DATA_HOME:-$HOME/.local/share}/OmniVoice"
fi
HF_CACHE="${HF_HOME:-$HOME/.cache/huggingface}"
# ── Flags ──────────────────────────────────────────────────────────────────
SKIP_BUILD=false
KEEP_DATA=false
SKIP_MODEL=false
for arg in "$@"; do
case "$arg" in
--skip-build) SKIP_BUILD=true ;;
--keep-data) KEEP_DATA=true ;;
--skip-model) SKIP_MODEL=true ;;
-h|--help)
echo "Usage: $0 [--skip-build] [--keep-data] [--skip-model]"
echo ""
echo " --skip-build Skip cargo build, use last compiled binary"
echo " --keep-data Don't wipe app data (test upgrade path)"
echo " --skip-model Skip waiting for TTS model load (saves time)"
exit 0
;;
esac
done
# ══════════════════════════════════════════════════════════════════════════
header "🧪 OmniVoice Studio — End-to-End Smoke Test"
echo " Platform: $PLATFORM | $(date)"
echo ""
# ── Phase 1: Clean ────────────────────────────────────────────────────────
header "Phase 1: Environment Reset"
if [ "$KEEP_DATA" = false ]; then
info "Wiping app data for fresh install simulation..."
for dir in "$APP_DATA" "$OV_DATA"; do
if [ -d "$dir" ]; then
rm -rf "$dir"
pass "Removed: $dir"
fi
done
pass "Clean slate — next launch bootstraps from zero"
else
warn "Keeping existing data (upgrade test mode)"
fi
# Kill any existing backend on port 3900
if lsof -i :3900 >/dev/null 2>&1; then
info "Killing existing process on port 3900..."
kill $(lsof -ti :3900) 2>/dev/null || true
sleep 1
fi
# ── Phase 2: Build ────────────────────────────────────────────────────────
header "Phase 2: Build"
BINARY="${TAURI_DIR}/target/debug/omnivoice-studio"
if [ "$SKIP_BUILD" = false ]; then
info "Building debug bundle (this takes 1-3 min)..."
# Remove stale bundle
if [ "$PLATFORM" = "macos" ]; then
APP_BUNDLE="${TAURI_DIR}/target/debug/bundle/macos/${APP_NAME}.app"
[ -d "$APP_BUNDLE" ] && rm -rf "$APP_BUNDLE"
fi
BUILD_LOG=$(mktemp)
cd frontend
set +e
bunx tauri build --debug >"$BUILD_LOG" 2>&1
BUILD_EXIT=$?
set -e
cd ..
if [ $BUILD_EXIT -ne 0 ]; then
if grep -qi "TAURI_SIGNING_PRIVATE_KEY\|private key\|failed to bundle" "$BUILD_LOG"; then
warn "Non-fatal bundle warning (signing/bundling) — binary is fine"
else
echo ""
tail -20 "$BUILD_LOG"
rm -f "$BUILD_LOG"
fail "Build failed with exit code $BUILD_EXIT"
exit 1
fi
fi
rm -f "$BUILD_LOG"
if [ -f "$BINARY" ]; then
pass "Binary built: $BINARY"
else
fail "Binary not found at $BINARY"
exit 1
fi
else
if [ -f "$BINARY" ]; then
pass "Using existing binary: $BINARY (--skip-build)"
else
fail "No binary found. Run without --skip-build first."
exit 1
fi
fi
# ── Phase 3: Launch & Bootstrap ───────────────────────────────────────────
header "Phase 3: Launch & Bootstrap"
info "Starting app..."
"$BINARY" &
APP_PID=$!
info "App PID: $APP_PID"
# Wait for backend to come up
info "Waiting for backend health (timeout: ${BOOTSTRAP_TIMEOUT}s)..."
ELAPSED=0
INTERVAL=5
while [ $ELAPSED -lt $BOOTSTRAP_TIMEOUT ]; do
if curl -sf "${BACKEND_URL}/system/info" >/dev/null 2>&1; then
pass "Backend healthy after ${ELAPSED}s"
break
fi
sleep $INTERVAL
ELAPSED=$((ELAPSED + INTERVAL))
# Check if app crashed
if ! kill -0 "$APP_PID" 2>/dev/null; then
fail "App process died during bootstrap (after ${ELAPSED}s)"
echo ""
# Show crash log if available
CRASH_LOG="$OV_DATA/crash_log.txt"
if [ -f "$CRASH_LOG" ] && [ -s "$CRASH_LOG" ]; then
echo " 📋 Crash log:"
tail -20 "$CRASH_LOG" | sed 's/^/ /'
fi
exit 1
fi
done
if [ $ELAPSED -ge $BOOTSTRAP_TIMEOUT ]; then
fail "Backend did not start within ${BOOTSTRAP_TIMEOUT}s"
exit 1
fi
# ── Phase 4: Health Checks ────────────────────────────────────────────────
header "Phase 4: Health Checks"
check_endpoint() {
local name="$1"
local url="$2"
local jq_filter="${3:-}"
TESTS=$((TESTS + 1))
RESPONSE=$(curl -sf "$url" 2>/dev/null) || { fail "$name — HTTP error"; return; }
if [ -n "$jq_filter" ]; then
VALUE=$(echo "$RESPONSE" | python3 -c "import sys,json; d=json.load(sys.stdin); print($jq_filter)" 2>/dev/null)
if [ -n "$VALUE" ] && [ "$VALUE" != "None" ]; then
pass "$name → $VALUE"
else
fail "$name — unexpected response"
fi
else
pass "$name → OK"
fi
}
# Core endpoints
check_endpoint "GET /system/info" "${BACKEND_URL}/system/info" "d.get('device','?')"
check_endpoint "GET /sysinfo" "${BACKEND_URL}/sysinfo"
check_endpoint "GET /model/status" "${BACKEND_URL}/model/status" "d.get('status','?')"
# Device detection — the most critical check
TESTS=$((TESTS + 1))
DEVICE=$(curl -sf "${BACKEND_URL}/system/info" | python3 -c "import sys,json; print(json.load(sys.stdin).get('device','unknown'))" 2>/dev/null)
case "$DEVICE" in
mps|cuda|xpu|cpu)
pass "Device detection: $DEVICE"
;;
*)
if echo "$DEVICE" | grep -q "privateuseone"; then
pass "Device detection: DirectML ($DEVICE)"
else
fail "Device detection returned unexpected: $DEVICE"
fi
;;
esac
# Python version check
TESTS=$((TESTS + 1))
PY_VER=$(curl -sf "${BACKEND_URL}/system/info" | python3 -c "import sys,json; print(json.load(sys.stdin).get('python','?'))" 2>/dev/null)
if echo "$PY_VER" | grep -q "^3\.11"; then
pass "Python version: $PY_VER"
else
fail "Python version unexpected: $PY_VER (expected 3.11.x)"
fi
# Platform check
TESTS=$((TESTS + 1))
PLAT=$(curl -sf "${BACKEND_URL}/system/info" | python3 -c "import sys,json; print(json.load(sys.stdin).get('platform','?'))" 2>/dev/null)
if [ "$PLAT" = "darwin" ] || [ "$PLAT" = "linux" ] || [ "$PLAT" = "win32" ]; then
pass "Platform: $PLAT"
else
fail "Platform unexpected: $PLAT"
fi
# FFmpeg check — try the sysinfo endpoint
TESTS=$((TESTS + 1))
FFMPEG_OK=$(curl -sf "${BACKEND_URL}/system/info" | python3 -c "
import sys,json
d=json.load(sys.stdin)
print('ok' if d.get('error') is None else d.get('error','?'))
" 2>/dev/null)
if [ "$FFMPEG_OK" = "ok" ]; then
pass "No startup errors"
else
fail "Startup error: $FFMPEG_OK"
fi
# WebSocket endpoint — curl GET returns 403/400/404 (needs WS upgrade)
# We just verify the route exists by checking it doesn't hard-error.
TESTS=$((TESTS + 1))
WS_CODE=$(curl -s -o /dev/null -w "%{http_code}" "${BACKEND_URL}/ws/events" 2>/dev/null || echo "000")
if [ "$WS_CODE" != "000" ]; then
pass "WebSocket route reachable (/ws/events → HTTP $WS_CODE)"
else
fail "WebSocket route unreachable (connection refused)"
fi
# System notifications endpoint
check_endpoint "GET /system/notifications" "${BACKEND_URL}/system/notifications"
# ── Phase 5: Model Loading (optional) ─────────────────────────────────────
if [ "$SKIP_MODEL" = false ]; then
header "Phase 5: Model Loading"
info "Waiting for TTS model to load (timeout: ${MODEL_TIMEOUT}s)..."
ELAPSED=0
while [ $ELAPSED -lt $MODEL_TIMEOUT ]; do
STATUS=$(curl -sf "${BACKEND_URL}/model/status" | python3 -c "import sys,json; print(json.load(sys.stdin).get('status','?'))" 2>/dev/null)
SUB=$(curl -sf "${BACKEND_URL}/model/status" | python3 -c "import sys,json; print(json.load(sys.stdin).get('sub_stage','?'))" 2>/dev/null)
if [ "$STATUS" = "ready" ]; then
TESTS=$((TESTS + 1))
pass "Model loaded successfully (${ELAPSED}s)"
break
elif [ "$STATUS" = "loading" ]; then
info "Loading... ($SUB) [${ELAPSED}s]"
fi
sleep 10
ELAPSED=$((ELAPSED + 10))
done
if [ $ELAPSED -ge $MODEL_TIMEOUT ]; then
TESTS=$((TESTS + 1))
ERR=$(curl -sf "${BACKEND_URL}/model/status" | python3 -c "import sys,json; print(json.load(sys.stdin).get('error','unknown'))" 2>/dev/null)
fail "Model did not load within ${MODEL_TIMEOUT}s (error: $ERR)"
fi
else
warn "Skipping model load test (--skip-model)"
fi
# ── Phase 6: Region Config ────────────────────────────────────────────────
header "Phase 6: Config Verification"
TESTS=$((TESTS + 1))
CONFIG_FILE="$APP_DATA/config.json"
if [ -f "$CONFIG_FILE" ]; then
REGION=$(python3 -c "import json; print(json.load(open('$CONFIG_FILE')).get('region','?'))" 2>/dev/null)
pass "Region config: $REGION (from $CONFIG_FILE)"
else
# No config file = default "auto" — that's correct for fresh install
pass "Region config: auto (default, no config.json yet)"
fi
# ── Phase 7: Data Directory Structure ─────────────────────────────────────
header "Phase 7: Data Directories"
for dir in "$OV_DATA" "$APP_DATA"; do
TESTS=$((TESTS + 1))
if [ -d "$dir" ]; then
pass "Directory exists: $(basename $dir)/"
else
warn "Directory missing: $dir (may be created on first use)"
fi
done
# ── Results ────────────────────────────────────────────────────────────────
header "═══════════════════════════════════════════════════════════"
echo ""
if [ $FAILURES -eq 0 ]; then
echo -e " ${GREEN}${BOLD}ALL TESTS PASSED${NC} ($TESTS checks)"
echo ""
echo -e " ${CYAN}The app bootstraps correctly from zero and all"
echo -e " endpoints are healthy. This matches the end-user experience.${NC}"
else
echo -e " ${RED}${BOLD}$FAILURES FAILURE(S)${NC} out of $TESTS checks"
echo ""
echo -e " ${RED}Fix the failures above before releasing.${NC}"
fi
echo ""
echo " Logs: ~/Library/Logs/OmniVoice/"
echo " Data: $APP_DATA"
echo ""
exit $FAILURES