* test(probe): expand coverage — dubbing, i18n, engines, security, migration, dictation, design, coverage-critic Broadens the probe harness from one happy-path spec per layer to whole-app feature coverage (web, backend, dictation, clone, design), keeping the Actor/Judge split and offline-by-default + enable-on-demand for heavy paths. New specs + judges (one subprocess boot shared across backend-touching specs): - dubbing (L4): segment duration-ratio, SRT/VTT well-formed, export-archive contents, output language-ID (advisory) - i18n: locale files valid JSON (gate); orphan-keys + coverage (advisory). NOTE: surfaced a real bug — all 20 non-en locales carry gallery.cat_*/ bootstrap.lines keys absent from the en reference (reported, not gated). - engine matrix: active engine available + every unavailable engine explains why (11 TTS / 7 ASR backends via /engines/*) - loopback security: system routes reject non-loopback origins (403) - DB migration: alembic UPGRADE on the seeded omnivoice_data fixture - Coverage Critic: every declared layer still has a spec (gate) + API inventory - dictation: streaming-ASR WebSocket /ws/transcribe registered + handshake - voice design: reuses the audio-correctness ladder - real ASR round-trip: enable-on-demand (PROBE_E2E=1) Enriched _boot_runner.py to capture engines/asr/loopback/openapi/ws in ONE isolated boot (conftest boot_capture session fixture); added env.seeded_data_dir. 13 specs total. probe suite 74 passed / 5 skipped; full repo 687 passed, 0 failures. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(probe): address all 15 unresolved review findings on #247 - coverage.py:22 — use `with open(...)` context to close spec files after yaml.safe_load (file handle leak) - _boot_runner.py:80 — store only `type(exc).__name__` for WS errors; drop raw str(exc) that could leak home paths / secrets into capture JSON - _boot_runner.py:99 — snapshot DB files before boot; set db_created=True only when boot creates NEW files (not when fixture already had one) - dubbing.py:46 — FAIL segments_duration_ratio when validated==0 (guards against empty/corrupt segment list passing vacuously) - i18n.py:49 — FAIL locale_valid_json when locales_dir is empty/missing - i18n.py:7 — fix docstring: locale_no_orphan_keys is advisory, not blocking - test_probe_i18n.py:59 — assert r.passed is False, not just r.advisory - coverage_critic.probe.yaml:15 — add "meta" to required layers list - dub_export.probe.yaml:17 — capture dub_audio in steps before advisory reads it - migration.probe.yaml:13 — add path_exists(db_path) data-integrity check - test_probe_asr_e2e.py:33 — os.path.exists → os.path.isfile for PROBE_ASR_SAMPLE - test_probe_migration.py:24 — assert context["db_path"] (presence) not db_created (new creation), aligning with the boot_runner fix Two findings intentionally skipped with reasons (see review thread replies): test_probe_design.py:36 — offline pattern is intentional; actor step is bypassed by design throughout the probe suite for CI compatibility test_probe_engines.py:22 — whisperx pin is intentional; it verifies the shipped default ASR engine is available out-of-the-box Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(probe): ASCII x in dubbing detail (ruff) + run migration judges inside seeded dir Two regressions from the hardening pass: - dubbing.py: replace non-ASCII '×' with 'x' (Ruff ambiguous-unicode → Tests lint fail) - test_probe_migration: move run_judges inside the seeded_data_dir with-block so the new path_exists check sees the DB before the temp dir is torn down (was always failing) --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
125 lines
5.2 KiB
Python
125 lines
5.2 KiB
Python
"""Out-of-process backend probe, executed by env.capture_first_run().
|
|
|
|
Runs in its OWN process so it never mutates the parent test session's module
|
|
table or environment (in-process booting purges/re-imports the backend, which
|
|
corrupts DB_PATH for other tests — see env.py). Boots the FastAPI app against a
|
|
data dir and captures, in a single boot, everything the L5/engine/security/
|
|
coverage probes need:
|
|
|
|
- first-run endpoints (health / system info / model status): status/body/latency
|
|
- DB creation on first boot
|
|
- TTS + ASR engine matrices (/engines/tts, /engines/asr)
|
|
- loopback-rejection of a system endpoint from a non-loopback origin
|
|
- the OpenAPI path inventory (for the Coverage Critic)
|
|
|
|
Writes the captured context as JSON to the output path. Not a test module.
|
|
|
|
argv: <data_dir> <output_json_path>
|
|
"""
|
|
|
|
import glob
|
|
import json
|
|
import os
|
|
import sys
|
|
import time
|
|
|
|
ENDPOINTS = [("/health", "health"), ("/system/info", "sysinfo"), ("/model/status", "model")]
|
|
|
|
|
|
def _db_files(data_dir: str) -> set:
|
|
"""Return the set of DB file paths currently present under data_dir."""
|
|
found = set()
|
|
for pat in ("*.sqlite3", "*.sqlite", "*.db"):
|
|
found.update(glob.glob(os.path.join(data_dir, "**", pat), recursive=True))
|
|
return found
|
|
|
|
|
|
def main() -> int:
|
|
data_dir, out_path = sys.argv[1], sys.argv[2]
|
|
os.environ["OMNIVOICE_MODEL"] = "test"
|
|
os.environ["OMNIVOICE_DISABLE_FILE_LOG"] = "1"
|
|
os.environ["OMNIVOICE_DATA_DIR"] = data_dir
|
|
|
|
repo_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
backend = os.path.join(repo_root, "backend")
|
|
if backend not in sys.path:
|
|
sys.path.insert(0, backend)
|
|
|
|
# Snapshot DB files that already exist BEFORE boot so we can detect
|
|
# creation (not just presence) — a pre-existing file does not mean this
|
|
# boot created it (e.g. seeded migration fixture already has a DB).
|
|
pre_boot_db_files = _db_files(data_dir)
|
|
|
|
from fastapi.testclient import TestClient
|
|
from main import app
|
|
|
|
ctx: dict = {}
|
|
with TestClient(app, client=("127.0.0.1", 50000)) as client: # ctx-enter fires lifespan → init_db()
|
|
for path, prefix in ENDPOINTS:
|
|
t0 = time.perf_counter()
|
|
resp = client.get(path)
|
|
ctx[f"{prefix}_ms"] = (time.perf_counter() - t0) * 1000.0
|
|
ctx[f"{prefix}_status"] = resp.status_code
|
|
try:
|
|
ctx[f"{prefix}_body"] = resp.json()
|
|
except Exception: # noqa: BLE001
|
|
ctx[f"{prefix}_body"] = None
|
|
|
|
# Engine matrices (loopback-gated routers → use the loopback client).
|
|
for fam in ("tts", "asr"):
|
|
r = client.get(f"/engines/{fam}")
|
|
ctx[f"engines_{fam}_status"] = r.status_code
|
|
try:
|
|
ctx[f"engines_{fam}"] = r.json()
|
|
except Exception: # noqa: BLE001
|
|
ctx[f"engines_{fam}"] = None
|
|
|
|
# Loopback security: a non-loopback origin must be rejected on system
|
|
# routes. Use a bare client (no `with`) so we don't re-enter the app
|
|
# lifespan — re-entry rebinds the module-level task queue to a new event
|
|
# loop and crashes. The require_loopback dependency only inspects
|
|
# request.client.host, which doesn't need lifespan state.
|
|
nl = TestClient(app) # default client host 'testclient' = non-loopback
|
|
ctx["loopback_reject_status"] = nl.get("/system/info").status_code
|
|
|
|
# Dictation: handshake-only connect to the streaming-ASR WebSocket
|
|
# (confirms the endpoint is wired + accepts loopback, without loading an
|
|
# ASR model). Immediately closing triggers the server's disconnect path.
|
|
try:
|
|
with client.websocket_connect("/ws/transcribe"):
|
|
ctx["ws_transcribe_connected"] = True
|
|
except Exception as exc: # noqa: BLE001
|
|
ctx["ws_transcribe_connected"] = False
|
|
# Store only the exception type — not the raw message — to avoid
|
|
# leaking absolute home paths or secret-like substrings that may
|
|
# appear in WebSocket handshake error text.
|
|
ctx["ws_transcribe_error"] = type(exc).__name__
|
|
|
|
# OpenAPI inventory (no client/lifespan needed). WebSocket routes aren't in
|
|
# the OpenAPI schema, so enumerate them from the route table separately.
|
|
try:
|
|
ctx["openapi_paths"] = sorted(app.openapi().get("paths", {}).keys())
|
|
except Exception: # noqa: BLE001
|
|
ctx["openapi_paths"] = []
|
|
ctx["ws_routes"] = sorted(
|
|
getattr(r, "path", "") for r in app.routes if "WebSocket" in type(r).__name__
|
|
)
|
|
|
|
ctx["data_dir"] = data_dir
|
|
post_boot_db_files = _db_files(data_dir)
|
|
new_db_files = post_boot_db_files - pre_boot_db_files
|
|
# db_path: any DB file present after boot (for reference by other judges)
|
|
all_db = sorted(post_boot_db_files)
|
|
ctx["db_path"] = all_db[0] if all_db else ""
|
|
# db_created: True only when this boot actually CREATED a new DB file
|
|
# (not just found a pre-existing one — e.g. the migration seeded fixture).
|
|
ctx["db_created"] = bool(new_db_files)
|
|
|
|
with open(out_path, "w", encoding="utf-8") as fh:
|
|
json.dump(ctx, fh)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|