Files
VoiceStudio/tests/probe/triage.py
T
Palash DebnathandClaude Opus 4.8 24a00bea64 test(probe): spec-driven AI-agent test harness (L1–L5 + HTML report + triage) (#245)
* test(probe): add spec-driven AI-agent test harness (L1/L2/L4/L5 + report + triage)

Introduces `tests/probe/`, a portable, mostly-deterministic test harness built
on the Actor/Judge split: AI agents may drive and self-heal, but verdicts are
always deterministic code + metrics — no LLM on the verdict path.

Layers:
- L1 API: Schemathesis property-fuzz over in-process ASGI (enable-on-demand).
- L2 web: Playwright Driver + deterministic self-heal (id→test-id→text, loosened
  CSS) → pluggable Healer; LLMHealer/anthropic_healer for genuine agentic heal.
  Judges + self-heal logic unit-tested offline via FakePage; live browser skips.
- L4 media: audio correctness — exists/decode/duration/not-silent/clipping/NaN,
  round-trip ASR WER (pure-python, faster-whisper backend), speaker similarity.
  No golden-WAV (device-stable metrics only); naturalness is advisory-only.
- L5 env/first-run: fresh-data-dir backend boot in a SUBPROCESS (no session
  contamination), asserts health + DB init + endpoint reachability. Docker gated.

Plus: hybrid YAML spec engine + JudgeResult/registry; self-contained HTML report
that auto-opens (suppressed in CI/headless/PROBE_NO_OPEN); Triager that clusters
failures and drafts a prefilled GitHub issue URL (sanitized, no auto-submit) with
a one-click button in the report.

Dependency-light: runs in the base venv; schemathesis/resemblyzer/playwright/
anthropic are enable-on-demand and skip cleanly. Generated reports gitignored.
Full suite green (657 passed); no contamination of existing tests.

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

* test(probe): add L3 desktop layer (Tauri config-integrity + guarded launch)

Per the architecture decision, desktop E2E is substituted by backend-over-HTTP
(L5) + browser (L2) since Tauri has no official macOS WebDriver. L3 guards the
packaging/shell contract a browser test can't see, against the real
tauri.conf.json (with platform-override merge), running on any platform with no
Tauri toolchain:

- version parity between tauri.conf.json and pyproject (release integrity)
- dev/build wiring (devUrl matches the Vite frontend, frontendDist, before* cmds)
- bundled binaries first-run depends on (uv / ffmpeg / ffprobe in externalBin)
- CSP actually permits the local backend origins (desktop-only failure mode:
  packaged app can't reach :3900 while the browser build works)

Adds desktop.py (config load + platform deep-merge + bundle discovery + launch
guard), judges/desktop.py (config_present/config_eq/config_contains/csp_allows),
desktop_smoke.probe.yaml, and tests covering integrity, platform-merge replace
semantics, and a live bundle launch that skips without a built bundle/display.

Full suite green (662 passed).

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 12:00:19 +05:30

119 lines
4.4 KiB
Python

"""Triager — turn a run's blocking failures into a prefilled GitHub issue.
Post-run, safe, high-value, and deterministic (no LLM on this path): cluster +
dedupe the blocking failures, sanitize them (strip home dirs, redact token-ish
strings — per the local-first privacy rules), and build a **prefilled-URL**
issue the way OmniVoice's in-app bug reporter does. It never auto-submits and
never holds a credential — it produces a `github.com/.../issues/new?...` URL the
user reviews and submits from their browser.
"""
from __future__ import annotations
import re
import subprocess
import urllib.parse
from dataclasses import dataclass, field
from .report import Report
# Privacy: collapse user home paths and redact obvious secret tokens.
_HOME_RE = re.compile(r"/(?:home|Users)/[^/\s'\"]+")
_SECRET_RE = re.compile(r"\b(?:hf_[A-Za-z0-9]{6,}|sk-[A-Za-z0-9]{6,}|ghp_[A-Za-z0-9]{6,})\b")
@dataclass
class FailureCluster:
signature: str # layer:feature:judge
layer: str
feature: str
judge: str
count: int
sample_detail: str
@dataclass
class TriageResult:
clusters: list[FailureCluster] = field(default_factory=list)
title: str = ""
body: str = ""
url: str | None = None
owner: str | None = None
repo: str | None = None
def sanitize(text: str) -> str:
if not text:
return text
text = _HOME_RE.sub("~", text)
return _SECRET_RE.sub("[REDACTED]", text)
def detect_repo(cwd: str | None = None) -> tuple[str, str] | None:
"""Parse ``owner/repo`` from the origin remote (https or ssh). None if absent
— the harness works without a GitHub remote, the report just omits the link."""
try:
out = subprocess.run(
["git", "remote", "get-url", "origin"],
cwd=cwd, capture_output=True, text=True, timeout=5,
)
except Exception: # noqa: BLE001
return None
if out.returncode != 0:
return None
m = re.search(r"github\.com[:/]+([^/]+)/(.+?)(?:\.git)?/?$", out.stdout.strip())
return (m.group(1), m.group(2)) if m else None
def cluster_failures(report: Report) -> list[FailureCluster]:
"""Group blocking failures by (layer, feature, judge); advisory/skip excluded."""
clusters: dict[str, FailureCluster] = {}
for outcome in report.outcomes:
for r in outcome.results:
if r.advisory or r.passed is not False:
continue
sig = f"{outcome.layer}:{outcome.feature}:{r.name}"
if sig in clusters:
clusters[sig].count += 1
else:
clusters[sig] = FailureCluster(
signature=sig, layer=outcome.layer, feature=outcome.feature,
judge=r.name, count=1, sample_detail=sanitize(r.detail),
)
return list(clusters.values())
def build_issue(clusters: list[FailureCluster]) -> tuple[str, str]:
n = sum(c.count for c in clusters)
feats = len({c.feature for c in clusters})
title = f"probe: {n} failing check{'s' if n != 1 else ''} across {feats} feature{'s' if feats != 1 else ''}"
lines = [
"Automated failure report from the **probe** test harness.",
"",
f"**{n} blocking failures** in {feats} feature(s). Advisory and skipped checks are excluded.",
"",
"| Layer | Feature | Check | Count | Detail |",
"|---|---|---|---|---|",
]
for c in sorted(clusters, key=lambda x: (-x.count, x.signature)):
detail = c.sample_detail.replace("|", "\\|").replace("\n", " ")[:200]
lines.append(f"| {c.layer} | {c.feature} | `{c.judge}` | {c.count} | {detail} |")
lines += ["", "_Generated by probe — review before submitting. Home paths stripped, tokens redacted._"]
return title, "\n".join(lines)
def issue_url(owner: str, repo: str, title: str, body: str, labels: tuple[str, ...] = ("probe", "bug")) -> str:
query = urllib.parse.urlencode({"title": title, "body": body, "labels": ",".join(labels)})
return f"https://github.com/{owner}/{repo}/issues/new?{query}"
def triage(report: Report, cwd: str | None = None, labels: tuple[str, ...] = ("probe", "bug")) -> TriageResult:
clusters = cluster_failures(report)
title, body = build_issue(clusters)
repo = detect_repo(cwd)
url = issue_url(repo[0], repo[1], title, body, labels) if (repo and clusters) else None
return TriageResult(
clusters=clusters, title=title, body=body, url=url,
owner=repo[0] if repo else None, repo=repo[1] if repo else None,
)