* 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>
119 lines
4.4 KiB
Python
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,
|
|
)
|