Add c3t — the club-3090 test-console TUI (built by Qwen Max from the spec) (#428)

A lazydocker-style terminal UI for the stack's test scripts (verify / bench /
verify-stress-NIAH / quality / soak / rebench-full): auto-detects the running
model + endpoint, injects MODEL=/URL=, streams live progress, and browses the
results/ history. Built by Qwen Max from docs/club3090-test-tui-prompt.md.

- scripts/c3t — launcher that derives the repo root from its own location and
  runs the tool from tools/test-console's own venv (no global installs).
- tools/test-console/ — Python + Textual package, pinned pyproject + uv.lock,
  and an 83-test offline pytest suite (parsers vs fixtures, mocked docker /
  /v1/models detection, BENCH_MOCK bench parse) — all green, no GPU required.

Fixed before merge: __main__.py and a test fixture hardcoded the rig path
/opt/ai/github/club-3090 — now derived from __file__ (parents[3]; override via
C3T_REPO_ROOT) so it works from any clone, not just this rig. .gitignore
excludes .venv / caches.

Co-authored-by: noonghunna <[email protected]>
Co-authored-by: Claude Opus 4.8 <[email protected]>
This commit is contained in:
noonghunna
2026-06-18 07:31:02 +05:00
committed by GitHub
co-authored by noonghunna Claude Opus 4.8
parent 79173b12d5
commit 496f82d899
23 changed files with 4719 additions and 0 deletions
Executable
+33
View File
@@ -0,0 +1,33 @@
#!/usr/bin/env bash
#
# c3t — launcher for the club3090 test console TUI
#
# Runs the tool from its own isolated env (uv run / .venv), never the system Python.
#
set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
TOOL_DIR="${SCRIPT_DIR}/../tools/test-console"
# Ensure we're in the repo root for cwd resolution
REPO_ROOT="${SCRIPT_DIR}/.."
if [[ ! -d "${TOOL_DIR}" ]]; then
echo "Error: test-console tool dir not found at ${TOOL_DIR}" >&2
exit 1
fi
cd "${REPO_ROOT}"
# Prefer uv if available, fall back to .venv, fall back to pipx
if command -v uv &>/dev/null; then
exec uv run --project "${TOOL_DIR}" -- python -m club3090_test_console "$@"
elif [[ -d "${TOOL_DIR}/.venv" ]]; then
exec "${TOOL_DIR}/.venv/bin/python" -m club3090_test_console "$@"
elif command -v pipx &>/dev/null; then
exec pipx run --spec "${TOOL_DIR}" c3t "$@"
else
echo "Error: No Python runner found. Install uv, create a .venv, or install pipx." >&2
echo " Quick setup: cd ${TOOL_DIR} && python3 -m venv .venv && .venv/bin/pip install -e ." >&2
exit 1
fi
+6
View File
@@ -0,0 +1,6 @@
.venv/
__pycache__/
*.py[cod]
.pytest_cache/
.ruff_cache/
*.egg-info/
+107
View File
@@ -0,0 +1,107 @@
# club3090-test-console (c3t)
A lazydocker-style terminal UI for the club-3090 AI inference stack test suite.
## What it does
The TUI wraps the stack's existing test scripts (`bench.sh`, `verify-full.sh`, `quality-test.sh`, etc.) and:
1. **Auto-detects** the serving model + endpoint (no more wrong-port/wrong-MODEL headaches)
2. **Runs tests** with one keystroke — full pipeline or individual tests
3. **Streams live progress** with structured parsing (bench TPS bars, NIAH ladder, quality counters, soak gauges)
4. **Shows GPU stats** (VRAM, utilization, power draw/cap, temperature) in real-time
## Quick start
```bash
# From the repo root:
bash scripts/c3t
# Or install with uv:
cd tools/test-console
uv sync
uv run c3t
```
## Setup
```bash
cd tools/test-console
# Option A: uv (recommended)
uv sync
# Option B: pip + venv
python3 -m venv .venv
source .venv/bin/activate
pip install -e .
```
## Keybindings
| Key | Action |
|-----|--------|
| `↑/↓` or `j/k` | Move in test menu |
| `Enter` | Run selected test (default config) |
| `c` | Configure selected test |
| `x` | Stop current run |
| `r` | Re-detect serving target |
| `m` | Manual target override |
| `f` | Toggle log follow/scroll-lock |
| `Tab` | Cycle pane focus |
| `?` | Help |
| `q` | Quit |
## Test catalog
| Test | Script | Duration |
|------|--------|----------|
| Smoke | `verify.sh` | ~15s |
| Functional | `verify-full.sh` | ~2min |
| Speed bench | `bench.sh` | ~5min |
| Stress / NIAH | `verify-stress.sh` | ~15min |
| Quality packs | `quality-test.sh` | 5-90min |
| Soak / stability | `soak-test.sh` | ~20min |
| ★ FULL rebench | `rebench-full.sh` | ~45min-4h |
## Architecture
```
tools/test-console/
├── pyproject.toml
├── README.md
├── club3090_test_console/
│ ├── __init__.py
│ ├── __main__.py # Entry point
│ ├── app.py # Main Textual App
│ ├── app.tcss # Textual CSS
│ ├── detect.py # Endpoint/model auto-detection
│ ├── parsers.py # Output parsers per test
│ ├── runner.py # Subprocess management
│ └── widgets/
│ ├── target_pane.py # Target status display
│ ├── test_menu.py # Test selection menu
│ └── live_pane.py # Structured progress + log
└── tests/
├── test_parsers.py # Parser unit tests (offline)
└── test_detect.py # Detection unit tests (offline)
```
## Development
```bash
# Run tests (no GPU needed)
cd tools/test-console
uv run pytest -v
# Run the TUI
uv run c3t
```
## Design decisions
- **Python + Textual** over Go+BubbleTea: reuses the stack's Python ecosystem and can import `compose_registry` directly
- **No script modifications**: the TUI is a pure wrapper — it spawns existing scripts and parses their output
- **No global installs**: uses `uv` / project-local `.venv` for isolation
- **XDG state**: run history persists under `~/.local/state/club3090-test-console/`, never in the repo tree
- **Headless-testable**: all parsers and detection logic are unit-testable without a GPU
@@ -0,0 +1,3 @@
"""club3090-test-console: A lazydocker-style TUI for the AI inference stack test suite."""
__version__ = "0.1.0"
@@ -0,0 +1,54 @@
"""Entry point for c3t command."""
import os
import sys
from pathlib import Path
def _suppress_event_loop_cleanup_error():
"""Suppress harmless 'Event loop is closed' errors from asyncio subprocess cleanup."""
# Python 3.8+ has sys.unraisablehook for exceptions in __del__ methods
if hasattr(sys, 'unraisablehook'):
original_hook = sys.unraisablehook
def filtered_hook(unraisable):
# Suppress 'Event loop is closed' from asyncio subprocess cleanup
if (unraisable.exc_type is RuntimeError and
unraisable.exc_value and
'Event loop is closed' in str(unraisable.exc_value)):
return
# Also suppress 'Cannot run the event loop while another loop is running'
if (unraisable.exc_type is RuntimeError and
unraisable.exc_value and
'loop is' in str(unraisable.exc_value).lower()):
return
original_hook(unraisable)
sys.unraisablehook = filtered_hook
def main():
"""Launch the test console TUI."""
_suppress_event_loop_cleanup_error()
# Resolve the repo root from this file's location (…/<repo>/tools/test-console/
# club3090_test_console/__main__.py → parents[3] == repo root), so the tool works
# from any clone. Override with C3T_REPO_ROOT if the package is installed elsewhere.
env_root = os.environ.get("C3T_REPO_ROOT")
repo_root = Path(env_root) if env_root else Path(__file__).resolve().parents[3]
if not (repo_root / "scripts").is_dir():
print(
f"Error: club-3090 repo root not found at {repo_root} "
f"(no scripts/ dir). Run via scripts/c3t, or set C3T_REPO_ROOT.",
file=sys.stderr,
)
sys.exit(1)
from .app import TestConsoleApp
app = TestConsoleApp(repo_root=repo_root)
app.run()
if __name__ == "__main__":
main()
@@ -0,0 +1,765 @@
"""Main Textual application for the club3090 test console."""
from __future__ import annotations
import asyncio
import json
import time
from pathlib import Path
from typing import Optional
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.containers import Container, Horizontal, Vertical
from textual.screen import ModalScreen
from textual.widgets import Footer, Header, Input, Label, Static, Button, RichLog, Select
from .detect import ServingTarget, detect_endpoint, detect_from_registry, match_target_to_registry, get_gpu_info
from .parsers import ParseEvent, TestType, Status
from .runner import TestConfig, TestRunner, RunState
from .widgets.target_pane import TargetPane
from .widgets.test_menu import TestMenuPane, TestEntry
from .widgets.live_pane import LivePane
from .widgets.history_view import HistoryScreen
from .widgets.manual_target import ManualTargetScreen
class ConfigScreen(ModalScreen[Optional[TestConfig]]):
"""Modal screen for configuring a test run."""
DEFAULT_CSS = """
ConfigScreen {
align: center middle;
}
ConfigScreen > Vertical {
width: 70;
height: auto;
max-height: 90%;
border: thick $primary;
background: $surface;
padding: 1 2;
}
ConfigScreen #config-fields {
height: auto;
max-height: 1fr;
overflow-y: auto;
}
ConfigScreen .config-title {
text-style: bold;
color: $accent;
text-align: center;
margin-bottom: 1;
}
ConfigScreen .config-hint {
color: $text-muted;
height: auto;
}
ConfigScreen Label {
margin-top: 1;
}
ConfigScreen Select {
margin-bottom: 1;
}
ConfigScreen Input {
margin-bottom: 1;
}
ConfigScreen #button-bar {
height: 3;
}
ConfigScreen Button {
width: 1fr;
}
"""
BINDINGS = [
Binding("escape", "cancel", "Cancel"),
]
def __init__(self, test_entry: TestEntry, current_config: Optional[TestConfig] = None):
super().__init__()
self.test_entry = test_entry
self._config = current_config or TestConfig(test_type=test_entry.test_type)
def compose(self) -> ComposeResult:
with Vertical():
yield Label(f"Configure: {self.test_entry.display_name}", classes="config-title")
yield Label(self._get_help_text(), classes="config-hint")
with Vertical(id="config-fields"):
yield from self._compose_config_fields()
with Horizontal(id="button-bar"):
yield Button("Run", variant="primary", id="btn-run")
yield Button("Cancel", variant="default", id="btn-cancel")
def _get_help_text(self) -> str:
match self.test_entry.test_type:
case TestType.BENCH:
return "Tune RUNS, WARMUPS, prompt selection, and thinking mode."
case TestType.VERIFY_FULL:
return "Optionally skip tool-call check or add a bench run."
case TestType.VERIFY_STRESS:
return "Control which stress probes to run (longctx, tool-prefill, ceiling)."
case TestType.QUALITY:
return "Choose tier, pack, thinking mode, and repeat count."
case TestType.SOAK:
return "Set session/turn counts and VRAM growth limits."
case TestType.REBENCH:
return "Configure 8-pack quality toggle, skips, and soak sizing."
case _:
return "Configure test parameters."
def _compose_config_fields(self) -> ComposeResult:
"""Yield config fields based on test type."""
tt = self.test_entry.test_type
c = self._config
# Helper for boolean select
bool_options = [("No", False), ("Yes", True)]
if tt == TestType.BENCH:
yield Label("RUNS (measured runs per prompt):")
yield Input(str(c.run_count), id="cfg-runs", type="integer")
yield Label("WARMUPS:")
yield Input(str(c.warmups), id="cfg-warmups", type="integer")
yield Label("ONLY (prompt selection):")
yield Select(
[("Both (narrative + code)", "both"), ("Narrative only", "narr"), ("Code only", "code")],
value=c.only,
id="cfg-only"
)
yield Label("Enable thinking:")
yield Select(bool_options, value=c.enable_thinking, id="cfg-thinking")
elif tt == TestType.VERIFY_FULL:
yield Label("Skip tools check:")
yield Select(bool_options, value=c.skip_tools, id="cfg-skip-tools")
yield Label("Run bench after:")
yield Select(bool_options, value=c.run_bench, id="cfg-run-bench")
elif tt == TestType.VERIFY_STRESS:
yield Label("Skip long-context:")
yield Select(bool_options, value=c.skip_longctx, id="cfg-skip-longctx")
yield Label("Skip tool prefill:")
yield Select(bool_options, value=c.skip_tool_prefill, id="cfg-skip-prefill")
yield Label("Skip ceiling ladder:")
yield Select(bool_options, value=c.skip_ceiling, id="cfg-skip-ceiling")
elif tt == TestType.QUALITY:
yield Label("Tier:")
yield Select(
[("Quick (2 packs)", "quick"), ("Medium (5 packs)", "medium"),
("Full (8 packs)", "full"), ("Reasoning", "reasoning")],
value=c.quality_tier,
id="cfg-tier"
)
yield Label("Pack ID (empty = all for tier):")
yield Input(c.quality_pack, id="cfg-pack", placeholder="e.g., toolcall-15")
yield Label("Enable thinking:")
yield Select(bool_options, value=c.enable_thinking, id="cfg-thinking")
yield Label("Repeat count:")
yield Input(str(c.quality_repeat), id="cfg-repeat", type="integer")
yield Label("Max tokens (0 = default):")
yield Input(str(c.max_tokens) if c.max_tokens > 0 else "", id="cfg-max-tokens",
type="integer", placeholder="auto")
yield Label("Thinking max tokens (0 = default):")
yield Input(str(c.thinking_max_tokens) if c.thinking_max_tokens > 0 else "",
id="cfg-thinking-max-tokens", type="integer", placeholder="auto")
elif tt == TestType.SOAK:
yield Label("Mode:")
yield Select(
[("Fresh (cold start)", "fresh"), ("Continuous (warm)", "continuous"), ("Quick (short)", "quick")],
value=c.soak_mode,
id="cfg-mode"
)
yield Label("Sessions:")
yield Input(str(c.soak_sessions), id="cfg-sessions", type="integer")
yield Label("Turns per session:")
yield Input(str(c.soak_turns), id="cfg-turns", type="integer")
yield Label("Max VRAM growth (MiB):")
yield Input(str(c.soak_max_growth), id="cfg-growth", type="integer")
elif tt == TestType.REBENCH:
yield Label("8-pack thinking:")
yield Select(
[("None (fast gates only)", ""), ("Off", "off"), ("On", "on"), ("Both (promotion gate)", "both")],
value=c.rebench_8pack,
id="cfg-8pack"
)
yield Label("Skip steps (CSV: verify-full,bench,...):")
yield Input(",".join(c.rebench_skip), id="cfg-skip", placeholder="e.g., bench,soak")
yield Label("Resume:")
yield Select(bool_options, value=c.rebench_resume, id="cfg-resume")
yield Label("Tag (empty = auto):")
yield Input(c.rebench_tag, id="cfg-tag", placeholder="auto-generated")
yield Label("SOAK_SESSIONS:")
yield Input(str(c.soak_sessions), id="cfg-sessions", type="integer")
yield Label("Max tokens (0 = default):")
yield Input(str(c.max_tokens) if c.max_tokens > 0 else "", id="cfg-max-tokens",
type="integer", placeholder="auto")
yield Label("Thinking max tokens (0 = default):")
yield Input(str(c.thinking_max_tokens) if c.thinking_max_tokens > 0 else "",
id="cfg-thinking-max-tokens", type="integer", placeholder="auto")
def _read_config(self) -> TestConfig:
"""Read values from widgets into config."""
c = TestConfig(test_type=self.test_entry.test_type)
tt = self.test_entry.test_type
def get_select_val(id: str, default):
try:
widget = self.query_one(f"#{id}", Select)
val = widget.value
return val if val != Select.BLANK else default
except Exception:
return default
def get_input_val(id: str, default: str = "") -> str:
try:
return self.query_one(f"#{id}", Input).value
except Exception:
return default
def get_int(id: str, default: int = 0) -> int:
try:
val = get_input_val(id, str(default))
return int(val) if val else default
except ValueError:
return default
if tt == TestType.BENCH:
c.run_count = get_int("cfg-runs", 5)
c.warmups = get_int("cfg-warmups", 3)
c.only = get_select_val("cfg-only", "both")
c.enable_thinking = get_select_val("cfg-thinking", False)
elif tt == TestType.VERIFY_FULL:
c.skip_tools = get_select_val("cfg-skip-tools", False)
c.run_bench = get_select_val("cfg-run-bench", False)
elif tt == TestType.VERIFY_STRESS:
c.skip_longctx = get_select_val("cfg-skip-longctx", False)
c.skip_tool_prefill = get_select_val("cfg-skip-prefill", False)
c.skip_ceiling = get_select_val("cfg-skip-ceiling", False)
elif tt == TestType.QUALITY:
c.quality_tier = get_select_val("cfg-tier", "medium")
c.quality_pack = get_input_val("cfg-pack")
c.enable_thinking = get_select_val("cfg-thinking", False)
c.quality_repeat = get_int("cfg-repeat", 1)
c.max_tokens = get_int("cfg-max-tokens", 0)
c.thinking_max_tokens = get_int("cfg-thinking-max-tokens", 0)
elif tt == TestType.SOAK:
c.soak_mode = get_select_val("cfg-mode", "fresh")
c.soak_sessions = get_int("cfg-sessions", 10)
c.soak_turns = get_int("cfg-turns", 5)
c.soak_max_growth = get_int("cfg-growth", 200)
elif tt == TestType.REBENCH:
c.rebench_8pack = get_select_val("cfg-8pack", "")
skip_csv = get_input_val("cfg-skip")
c.rebench_skip = [s.strip() for s in skip_csv.split(",") if s.strip()]
c.rebench_resume = get_select_val("cfg-resume", False)
c.rebench_tag = get_input_val("cfg-tag")
c.soak_sessions = get_int("cfg-sessions", 10)
c.max_tokens = get_int("cfg-max-tokens", 0)
c.thinking_max_tokens = get_int("cfg-thinking-max-tokens", 0)
return c
async def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "btn-run":
config = self._read_config()
self.dismiss(config)
elif event.button.id == "btn-cancel":
self.dismiss(None)
def action_cancel(self) -> None:
self.dismiss(None)
class HelpScreen(ModalScreen):
"""Help overlay showing keybindings."""
DEFAULT_CSS = """
HelpScreen {
align: center middle;
}
HelpScreen > Vertical {
width: 70;
height: auto;
border: thick $accent;
background: $surface;
padding: 1 2;
}
HelpScreen .help-title {
text-style: bold;
color: $accent;
text-align: center;
margin-bottom: 1;
}
"""
BINDINGS = [
Binding("escape", "dismiss", "Close"),
Binding("question_mark", "dismiss", "Close"),
]
HELP_TEXT = """
[bold]Keybindings[/bold]
[cyan]↑/↓ or j/k[/cyan] Move selection in test menu
[cyan]Enter[/cyan] Run selected test (default config)
[cyan]c[/cyan] Configure selected test
[cyan]x[/cyan] Stop current run
[cyan]r[/cyan] Re-detect serving target
[cyan]m[/cyan] Manual target override
[cyan]f[/cyan] Toggle log follow/scroll-lock
[cyan]Tab[/cyan] Cycle pane focus
[cyan]1/2/3[/cyan] Jump to pane (Target/Tests/Live)
[cyan]?[/cyan] Show this help
[cyan]q[/cyan] Quit
[bold]Status glyphs[/bold]
[green]✓[/green] passed [red]✗[/red] failed [yellow]△[/yellow] partial/recall-miss
[yellow]⊘[/yellow] skipped [cyan]▶[/cyan] running ◔ queued
"""
def compose(self) -> ComposeResult:
with Vertical():
yield Label("club3090 test console — Help", classes="help-title")
yield Static(self.HELP_TEXT)
def action_dismiss(self) -> None:
self.app.pop_screen()
class QuitConfirmScreen(ModalScreen[bool]):
"""Confirm quit when a test is running. Offers stop & quit or cancel."""
DEFAULT_CSS = """
QuitConfirmScreen {
align: center middle;
}
QuitConfirmScreen > Vertical {
width: 60;
height: auto;
border: thick $warning;
background: $surface;
padding: 1 2;
}
QuitConfirmScreen .quit-title {
text-style: bold;
color: $warning;
text-align: center;
margin-bottom: 1;
}
QuitConfirmScreen .quit-info {
color: $text-muted;
text-align: center;
margin-bottom: 1;
}
QuitConfirmScreen Button {
margin: 1 1;
width: 1fr;
}
"""
BINDINGS = [
Binding("escape", "cancel", "Cancel"),
]
def __init__(self, test_name: str):
super().__init__()
self.test_name = test_name
def compose(self) -> ComposeResult:
with Vertical():
yield Label("⚠ Test Running", classes="quit-title")
yield Label(f"{self.test_name} is still running.", classes="quit-info")
yield Label("Choose an action:", classes="quit-info")
with Horizontal():
yield Button("Stop & Quit", variant="error", id="btn-stop-quit")
yield Button("Cancel", variant="default", id="btn-cancel")
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "btn-stop-quit":
self.dismiss(True) # stop and quit
elif event.button.id == "btn-cancel":
self.dismiss(False) # cancel
def action_cancel(self) -> None:
self.dismiss(False)
class TestConsoleApp(App):
"""The main club3090 test console application."""
TITLE = "club3090 test console"
CSS_PATH = "app.tcss"
BINDINGS = [
Binding("q", "safe_quit", "Quit", show=True),
Binding("question_mark", "help", "Help", show=True),
Binding("r", "redetect", "Re-detect", show=True),
Binding("c", "config", "Configure", show=True),
Binding("x", "stop", "Stop", show=True),
Binding("h", "history", "History", show=True),
Binding("f", "toggle_follow", "Follow", show=False),
Binding("m", "manual_target", "Target", show=True),
Binding("enter", "run_test", "Run", show=True),
]
def __init__(self, repo_root: Path, **kwargs):
super().__init__(**kwargs)
self.repo_root = repo_root
self.target: Optional[ServingTarget] = None
self.registry_variants: list[dict] = []
self.runner = TestRunner(repo_root)
self._gpu_refresh_task: Optional[asyncio.Task] = None
self._state_dir = Path.home() / ".local" / "state" / "club3090-test-console"
def compose(self) -> ComposeResult:
yield Header(show_clock=True)
with Horizontal(id="main-layout"):
with Vertical(id="left-rail"):
yield TargetPane(id="target-pane")
yield TestMenuPane(id="test-menu")
yield LivePane(id="live-pane")
yield Footer()
CSS = """
#main-layout {
height: 1fr;
}
#left-rail {
width: 38;
height: 1fr;
}
#target-pane {
height: auto;
min-height: 8;
}
#test-menu {
height: 1fr;
}
#live-pane {
width: 1fr;
height: 1fr;
}
"""
async def on_mount(self) -> None:
"""Initialize: detect target, load registry, set up runner."""
self.runner.set_callbacks(
on_event=self._on_run_event,
on_line=self._on_run_line,
on_complete=self._on_run_complete,
)
# Ensure state dir exists
self._state_dir.mkdir(parents=True, exist_ok=True)
# Start detection in background
asyncio.create_task(self._initial_detect())
# Set up elapsed timer update
self.set_interval(1.0, self._update_elapsed_timer)
async def on_unmount(self) -> None:
"""Clean up background tasks on app shutdown."""
if self._gpu_refresh_task and not self._gpu_refresh_task.done():
self._gpu_refresh_task.cancel()
# Don't await - just cancel to avoid event loop cleanup issues
async def _initial_detect(self) -> None:
"""Detect serving target and load registry."""
# Detect endpoint
self.target = await detect_endpoint()
# Load registry for enrichment
self.registry_variants = await detect_from_registry(str(self.repo_root))
if self.target and self.registry_variants:
self.target = match_target_to_registry(self.target, self.registry_variants)
# Update the target pane
self._update_target_pane()
# Cancel old GPU refresh loop if exists, then start new one
if self._gpu_refresh_task and not self._gpu_refresh_task.done():
self._gpu_refresh_task.cancel()
try:
await self._gpu_refresh_task
except asyncio.CancelledError:
pass
self._gpu_refresh_task = asyncio.create_task(self._gpu_refresh_loop())
async def _gpu_refresh_loop(self) -> None:
"""Periodically refresh GPU stats."""
try:
while True:
await asyncio.sleep(2)
if self.target and self.target.is_active:
self.target.gpus = await get_gpu_info()
self._update_target_pane()
except asyncio.CancelledError:
pass
def _update_elapsed_timer(self) -> None:
"""Update the elapsed timer in the live pane."""
try:
live = self.query_one("#live-pane", LivePane)
live.update_elapsed_timer()
except Exception:
pass
def _update_target_pane(self) -> None:
"""Update the target pane with current target info."""
try:
pane = self.query_one("#target-pane", TargetPane)
pane.target = self.target
except Exception:
pass
def _on_run_event(self, event: ParseEvent) -> None:
"""Called when the runner parses a structured event."""
# Capture test_type at scheduling time before current_run may be cleared
test_type = self.runner.current_run.test_type if self.runner.current_run else None
self.call_later(self._handle_run_event, event, test_type)
def _handle_run_event(self, event: ParseEvent, test_type: TestType | None = None) -> None:
"""Handle a run event on the UI thread."""
try:
live = self.query_one("#live-pane", LivePane)
if test_type:
live.process_event(event, test_type)
except Exception:
pass
def _on_run_line(self, line: str) -> None:
"""Called for every stdout line from the runner."""
self.call_later(self._handle_run_line, line)
def _handle_run_line(self, line: str) -> None:
"""Handle a raw log line on the UI thread."""
try:
live = self.query_one("#live-pane", LivePane)
live.append_line(line)
except Exception:
pass
def _on_run_complete(self, state: RunState) -> None:
"""Called when a test run completes."""
self.call_later(self._handle_run_complete, state)
def _handle_run_complete(self, state: RunState) -> None:
"""Handle run completion on the UI thread."""
# Update menu status
try:
menu = self.query_one("#test-menu", TestMenuPane)
status = "passed" if state.verdict == "passed" else "failed"
menu.set_status(state.test_type, status)
except Exception:
pass
# Save run record
self._save_run_record(state)
# Show completion in log
try:
live = self.query_one("#live-pane", LivePane)
elapsed = state.elapsed_s
if state.verdict == "passed":
live.append_line(f"\n[bold green]✓ {state.test_type.value} passed in {elapsed:.0f}s[/bold green]")
else:
live.append_line(f"\n[bold red]✗ {state.test_type.value} failed (rc={state.exit_code}) in {elapsed:.0f}s[/bold red]")
if state.report_path:
live.append_line(f" report: {state.report_path}")
if state.artifact_dir:
live.append_line(f" artifacts: {state.artifact_dir}")
except Exception:
pass
def _save_run_record(self, state: RunState) -> None:
"""Persist a run record to disk."""
runs_dir = self._state_dir / "runs"
runs_dir.mkdir(parents=True, exist_ok=True)
record = {
"test": state.test_type.value,
"model": state.target.model if state.target else "",
"url": state.target.url if state.target else "",
"slug": state.target.slug if state.target else "",
"started": state.started,
"finished": state.finished,
"exit_code": state.exit_code,
"verdict": state.verdict,
"elapsed_s": state.elapsed_s,
"artifact_dir": state.artifact_dir,
"report_path": state.report_path,
"power_cap_w": (
state.target.gpus[0].power_limit_w
if state.target and state.target.gpus
else None
),
}
ts = time.strftime("%Y%m%d-%H%M%S", time.localtime(state.started))
path = runs_dir / f"{ts}-{state.test_type.value}.json"
try:
path.write_text(json.dumps(record, indent=2))
except Exception:
pass
# ── Actions ──────────────────────────────────────────────────────────
async def action_run_test(self) -> None:
"""Run the selected test."""
menu = self.query_one("#test-menu", TestMenuPane)
entry = menu.get_selected_entry()
if not entry:
return
# Check if a run is active
if self.runner.current_run:
self.notify("A test is already running. Press x to stop first.", severity="warning")
return
# Check target
if not self.target or not self.target.is_active:
self.notify("No model serving. Start one with `gpu-mode <mode>`.", severity="error")
return
config = TestConfig(test_type=entry.test_type)
await self._start_run(config)
async def action_config(self) -> None:
"""Open config for the selected test."""
menu = self.query_one("#test-menu", TestMenuPane)
entry = menu.get_selected_entry()
if not entry:
return
def on_config_dismiss(config: Optional[TestConfig]) -> None:
if config:
self.run_worker(self._start_run(config))
self.push_screen(ConfigScreen(entry), callback=on_config_dismiss)
async def _start_run(self, config: TestConfig) -> None:
"""Start a test run with the given config."""
# Guard against starting a second run while one is already active
if self.runner.current_run:
self.notify("A test is already running. Press x to stop first.", severity="warning")
return
if not self.target:
self.notify("No serving target detected.", severity="error")
return
# Update menu status
try:
menu = self.query_one("#test-menu", TestMenuPane)
menu.set_status(config.test_type, "running")
except Exception:
pass
# Set up live pane
try:
live = self.query_one("#live-pane", LivePane)
live.set_run_header(config.test_type, self.target.model)
except Exception:
pass
# Start the run
await self.runner.start(config, self.target)
async def action_stop(self) -> None:
"""Stop the current run."""
if self.runner.current_run:
test_type = self.runner.current_run.test_type
orphans = await self.runner.cancel()
if orphans:
orphan_names = ' '.join(orphans)
self.notify(
f"Test cancelled. ⚠ Orphaned benchlocal containers: {', '.join(orphans)}. "
f"Run: docker rm -f {orphan_names}",
severity="warning",
timeout=10,
)
else:
self.notify("Test cancelled.", severity="warning")
try:
menu = self.query_one("#test-menu", TestMenuPane)
menu.set_status(test_type, "idle")
except Exception:
pass
else:
self.notify("No active run.", severity="information")
async def action_redetect(self) -> None:
"""Re-detect the serving target."""
self.notify("Re-detecting...", severity="information")
await self._initial_detect()
if self.target and self.target.is_active:
self.notify(f"Found: {self.target.model} on :{self.target.host_port}", severity="information")
else:
self.notify("No model serving.", severity="warning")
def action_help(self) -> None:
"""Show the help screen."""
self.push_screen(HelpScreen())
def action_toggle_follow(self) -> None:
"""Toggle log follow mode."""
try:
live = self.query_one("#live-pane", LivePane)
live.toggle_follow()
follow_state = "on" if live._follow else "off"
self.notify(f"Log follow: {follow_state}", severity="information")
except Exception:
pass
async def action_manual_target(self) -> None:
"""Open manual target override."""
variants = self.registry_variants
if not variants:
variants = await detect_from_registry(str(self.repo_root))
def on_target_dismiss(result: Optional[ServingTarget]) -> None:
if result:
self.target = result
self._update_target_pane()
self.notify(f"Target set: {result.model} @ {result.url}", severity="information")
self.push_screen(ManualTargetScreen(str(self.repo_root), variants), callback=on_target_dismiss)
def action_history(self) -> None:
"""Open run history view."""
self.push_screen(HistoryScreen(self._state_dir, self.repo_root))
async def action_safe_quit(self) -> None:
"""Quit with confirmation if a test is running."""
if not self.runner.current_run:
# No active run, just quit
self.exit()
return
# Show confirmation dialog
test_name = self.runner.current_run.test_type.value
def on_quit_choice(stop_and_quit: bool) -> None:
if stop_and_quit:
# Stop & quit
self.run_worker(self._stop_and_quit())
# else: cancel (do nothing)
self.push_screen(QuitConfirmScreen(test_name), callback=on_quit_choice)
async def _stop_and_quit(self) -> None:
"""Stop the current run and quit."""
if self.runner.current_run:
await self.runner.cancel()
self.exit()
@@ -0,0 +1,34 @@
/* club3090 test console — Textual CSS */
/* Dark theme by default */
Screen {
background: $surface;
}
#main-layout {
height: 1fr;
}
#left-rail {
width: 38;
height: 1fr;
min-width: 32;
max-width: 42;
}
#live-pane {
width: 1fr;
height: 1fr;
}
/* Pane borders — accent on focus */
TargetPane:focus-within,
TestMenuPane:focus-within,
LivePane:focus-within {
border: solid $accent;
}
/* Footer customization */
Footer {
dock: bottom;
}
@@ -0,0 +1,279 @@
"""Auto-detection of serving model + endpoint.
Replicates the logic from scripts/preflight.sh::preflight_autodetect_endpoint
and preflight_autodetect_model.
"""
from __future__ import annotations
import asyncio
import re
from dataclasses import dataclass, field
from typing import Optional
import httpx
# Engine-internal ports: 8000=vLLM, 8080=llama.cpp, 30000=SGLang
ENGINE_INTERNAL_PORTS = {"8000", "8080", "30000"}
# Recognized engine-family container prefixes
ENGINE_PREFIXES = re.compile(r"^(vllm-|llama-cpp-|ik-llama-|sglang-|beellama-)")
# Port mapping regex: matches 0.0.0.0:8011->8000/tcp, [::]:8011->8000/tcp, 127.0.0.1:8011->8000/tcp
PORT_MAP_RE = re.compile(
r"(?:[0-9]{1,3}(?:\.[0-9]{1,3}){3}|\[::\]):(\d+)->(8000|8080|30000)/tcp"
)
# All known engine-port patterns (also match without IP prefix)
PORT_MAP_BROAD_RE = re.compile(
r":(\d+)->(8000|8080|30000)/tcp"
)
@dataclass
class GpuInfo:
"""Information about a single GPU."""
index: int
utilization: int = 0 # %
mem_used_mib: int = 0
mem_total_mib: int = 0
power_draw_w: float = 0.0
power_limit_w: float = 0.0
temp_c: int = 0
@dataclass
class ServingTarget:
"""Resolved serving target — model + endpoint + metadata."""
url: str = ""
model: str = ""
container: str = ""
engine: str = "" # vllm | llamacpp | ik-llama | sglang | beellama | unknown
host_port: int = 0
internal_port: int = 0
slug: str = "" # registry slug if matched
kv_format: str = ""
max_ctx: int = 0
tp: int = 0
status: str = "" # registry status
status_note: str = ""
health: str = "unknown" # serving | unreachable | multiple
gpus: list[GpuInfo] = field(default_factory=list)
@property
def is_localhost(self) -> bool:
return "localhost" in self.url or "127." in self.url or "[::1]" in self.url
@property
def is_active(self) -> bool:
return bool(self.url and self.model and self.health == "serving")
def _classify_engine(internal_port: str) -> str:
"""Map internal port to engine family."""
return {"8000": "vllm", "8080": "llamacpp", "30000": "sglang"}.get(internal_port, "unknown")
def _classify_engine_from_container(name: str) -> str:
"""Refine engine from container name prefix."""
if name.startswith("vllm-"):
return "vllm"
if name.startswith("llama-cpp-") or name.startswith("ik-llama-"):
return "llamacpp"
if name.startswith("sglang-"):
return "sglang"
if name.startswith("beellama-"):
return "beellama"
return "unknown"
async def detect_endpoint(container_name: Optional[str] = None) -> ServingTarget:
"""Detect the currently-serving model and endpoint.
Args:
container_name: If set, detect only from this container.
Returns:
A ServingTarget with whatever was resolved.
"""
target = ServingTarget()
# Step 1: docker ps
try:
proc = await asyncio.create_subprocess_exec(
"docker", "ps", "--format", "{{.Names}}|{{.Ports}}",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=10)
lines = stdout.decode().strip().split("\n")
except (asyncio.TimeoutError, FileNotFoundError, Exception):
target.health = "unreachable"
return target
# Step 2: find inference containers
# Filter to recognized engine prefixes FIRST to exclude Open WebUI and other non-inference containers
candidates: list[tuple[str, int, int, str]] = [] # (name, host_port, internal_port, engine)
seen: set[tuple[str, int]] = set() # dedupe by (container_name, host_port) for dual-stack
for line in lines:
if "|" not in line:
continue
name, ports_str = line.split("|", 1)
# Only consider recognized engine containers
if not ENGINE_PREFIXES.match(name):
continue
for match in PORT_MAP_BROAD_RE.finditer(ports_str):
host_port = int(match.group(1))
internal_port = int(match.group(2))
# Dedupe dual-stack mappings (same container, same host port)
key = (name, host_port)
if key in seen:
continue
seen.add(key)
engine = _classify_engine_from_container(name)
if engine == "unknown":
engine = _classify_engine(str(internal_port))
candidates.append((name, host_port, internal_port, engine))
if not candidates:
target.health = "unreachable"
return target
# If caller pinned a container, filter to it
if container_name:
candidates = [c for c in candidates if c[0] == container_name]
if not candidates:
target.health = "unreachable"
return target
# Step 3: prefer recognized engine prefix; else first match
preferred = [c for c in candidates if ENGINE_PREFIXES.match(c[0])]
chosen = preferred[0] if preferred else candidates[0]
name, host_port, internal_port, engine = chosen
target.container = name
target.host_port = host_port
target.internal_port = internal_port
target.engine = engine
target.url = f"http://localhost:{host_port}"
# Check for truly different containers (not just multiple ports on same container)
unique_containers = set(c[0] for c in candidates)
if len(unique_containers) > 1:
target.health = "multiple"
else:
target.health = "serving"
# Step 4: probe /v1/models
try:
async with httpx.AsyncClient(timeout=5.0) as client:
resp = await client.get(f"{target.url}/v1/models")
if resp.status_code == 200:
data = resp.json().get("data", [])
if data:
target.model = data[0].get("id", "")
if target.health == "multiple":
pass # keep multiple
else:
target.health = "serving"
else:
target.health = "unreachable"
except Exception:
target.health = "unreachable"
# Step 5: GPU info
target.gpus = await get_gpu_info()
return target
async def get_gpu_info() -> list[GpuInfo]:
"""Query nvidia-smi for GPU stats."""
try:
proc = await asyncio.create_subprocess_exec(
"nvidia-smi",
"--query-gpu=index,utilization.gpu,memory.used,memory.total,power.draw,power.limit,temperature.gpu",
"--format=csv,noheader,nounits",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=10)
gpus = []
for line in stdout.decode().strip().split("\n"):
parts = [p.strip() for p in line.split(",")]
if len(parts) >= 7:
gpus.append(GpuInfo(
index=int(parts[0]),
utilization=int(float(parts[1])),
mem_used_mib=int(float(parts[2])),
mem_total_mib=int(float(parts[3])),
power_draw_w=float(parts[4]),
power_limit_w=float(parts[5]),
temp_c=int(float(parts[6])),
))
return gpus
except Exception:
return []
async def detect_from_registry(repo_root: str) -> list[dict]:
"""Enumerate registry variants via registry-emit.sh.
Returns list of dicts with keys: slug, engine, port, model, status, etc.
"""
try:
cmd = f'source "{repo_root}/scripts/lib/registry-emit.sh" && registry_variant_rows "{repo_root}"'
proc = await asyncio.create_subprocess_exec(
"bash", "-c", cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=repo_root,
)
stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=15)
variants = []
for line in stdout.decode().strip().split("\n"):
parts = line.split("\t")
if len(parts) >= 13 and parts[0] == "VARIANT":
variants.append({
"slug": parts[1],
"switch_engine": parts[2],
"launch_engine": parts[3],
"compose_dir": parts[4],
"file": parts[5],
"port": int(parts[6]) if parts[6].isdigit() else 0,
"model": parts[7],
"engine": parts[8],
"kvcalc_key": parts[9],
"container": parts[10],
"compose_path": parts[11],
"status": parts[12],
"ctx_label": parts[13] if len(parts) > 13 else "",
"status_note": parts[14] if len(parts) > 14 else "",
})
return variants
except Exception:
return []
def match_target_to_registry(target: ServingTarget, variants: list[dict]) -> ServingTarget:
"""Enrich a ServingTarget with registry metadata by matching port/container."""
for v in variants:
# Match by container name first, then port
if target.container and v.get("container", "").replace("_", "-") in target.container:
target.slug = v["slug"]
target.kv_format = v.get("kvcalc_key", "")
target.status = v.get("status", "")
target.status_note = v.get("status_note", "")
return target
if v["port"] == target.host_port:
target.slug = v["slug"]
target.kv_format = v.get("kvcalc_key", "")
target.status = v.get("status", "")
target.status_note = v.get("status_note", "")
return target
return target
@@ -0,0 +1,609 @@
"""Output parsers for each test script.
Each parser is a stateful class that processes lines and emits structured events.
Regex patterns are derived from the actual script output formats (Section 10 of spec).
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Optional
class TestType(str, Enum):
VERIFY = "verify"
VERIFY_FULL = "verify-full"
BENCH = "bench"
VERIFY_STRESS = "verify-stress"
QUALITY = "quality"
SOAK = "soak"
REBENCH = "rebench-full"
class Status(str, Enum):
QUEUED = "queued"
RUNNING = "running"
PASSED = "passed"
FAILED = "failed"
PARTIAL = "partial"
SKIPPED = "skipped"
def strip_ansi(text: str) -> str:
"""Strip ANSI/SGR escape codes."""
return re.sub(r"\x1b\[[0-9;]*m", "", text)
@dataclass
class ParseEvent:
"""A structured event parsed from a test output line."""
event_type: str # e.g., "bench_run", "summary_metric", "niah_rung", "verdict"
data: dict[str, Any] = field(default_factory=dict)
raw_line: str = ""
# ============================================================================
# bench.sh parser
# ============================================================================
class BenchParser:
"""Parser for bench.sh output."""
# Section: ========== NARRATIVE (prompt=65 chars, max_tokens=1000) ==========
SECTION_RE = re.compile(r"^========== (\w+) \(prompt=")
# Warmup/measured: run-1 wall= 11.53s ttft= 118ms toks=1000 wall_TPS= 86.68 decode_TPS= 87.58
RUN_RE = re.compile(
r"^\s+(warm|run)-(\d+)\s+wall=\s*([\d.]+)s\s+ttft=\s*(\d+)ms\s+"
r"toks=\s*(\d+)\s+wall_TPS=\s*([\d.]+)\s+decode_TPS=\s*([\d.]+)"
)
# Summary: wall_TPS mean= 84.14 std= 2.41 CV= 2.9%
SUMMARY_RE = re.compile(
r"^\s+(wall_TPS|decode_TPS)\s+mean=\s*([\d.]+).*CV=\s*([\d.]+)%"
)
# TTFT summary
TTFT_RE = re.compile(r"^\s+TTFT\s+mean=\s*(\d+)ms")
# Spec decoding metrics
SPEC_DEC_RE = re.compile(r"^=== Last \d+ SpecDecoding metrics ===")
def __init__(self):
self.current_section: Optional[str] = None
self.runs: dict[str, list[dict]] = {"narrative": [], "code": []}
self.summary: dict[str, dict] = {}
self.total_runs: int = 0
self.warmups: int = 0
self.measured_runs: int = 0
def parse_line(self, line: str) -> Optional[ParseEvent]:
"""Parse a single line and return an event if matched."""
clean = strip_ansi(line)
# Section header
m = self.SECTION_RE.match(clean)
if m:
self.current_section = m.group(1).lower()
return ParseEvent("bench_section", {"section": self.current_section}, line)
# Run line (warmup or measured)
m = self.RUN_RE.match(clean)
if m:
run_type, run_num = m.group(1), int(m.group(2))
data = {
"type": run_type,
"run": run_num,
"wall_s": float(m.group(3)),
"ttft_ms": int(m.group(4)),
"tokens": int(m.group(5)),
"wall_tps": float(m.group(6)),
"decode_tps": float(m.group(7)),
"section": self.current_section,
}
if self.current_section and run_type == "run":
self.runs[self.current_section].append(data)
return ParseEvent("bench_run", data, line)
# Summary metric
m = self.SUMMARY_RE.match(clean)
if m:
metric, mean, cv = m.group(1), float(m.group(2)), float(m.group(3))
data = {"metric": metric, "mean": mean, "cv": cv, "section": self.current_section}
if self.current_section:
self.summary.setdefault(self.current_section, {})[metric] = data
return ParseEvent("summary_metric", data, line)
# TTFT summary
m = self.TTFT_RE.match(clean)
if m:
data = {"ttft_mean_ms": int(m.group(1)), "section": self.current_section}
if self.current_section:
self.summary.setdefault(self.current_section, {})["ttft"] = data
return ParseEvent("summary_ttft", data, line)
# Spec decoding header
if self.SPEC_DEC_RE.match(clean):
return ParseEvent("spec_dec_header", {}, line)
return None
# ============================================================================
# verify.sh / verify-full.sh parser
# ============================================================================
class VerifyParser:
"""Parser for verify.sh and verify-full.sh output."""
# [3/9] Basic completion — capital of France ...
STEP_RE = re.compile(r"^\[(\d+)/(\d+)\] (.+?) \.\.\.")
# ✓ reply contains 'Paris'
# ✗ tool-call request failed
# ⊘ Genesis patches applied (skipped)
CHECK_RE = re.compile(r"^\s+([✓✗⊘]) (.+)")
# → Check docker logs vllm-qwen36-27b
HINT_RE = re.compile(r"^\s+→ (.+)")
# All checks passed.
PASS_RE = re.compile(r"^All checks passed\.")
# 3 check(s) failed.
FAIL_RE = re.compile(r"^(\d+) check\(s\) failed\.")
def __init__(self):
self.steps: list[dict] = []
self.current_step: Optional[dict] = None
self.total_steps: int = 0
self.passed: int = 0
self.failed: int = 0
self.skipped: int = 0
def parse_line(self, line: str) -> Optional[ParseEvent]:
clean = strip_ansi(line)
# Step header
m = self.STEP_RE.match(clean)
if m:
step_num, total, name = int(m.group(1)), int(m.group(2)), m.group(3)
self.total_steps = total
self.current_step = {"num": step_num, "total": total, "name": name, "checks": []}
self.steps.append(self.current_step)
return ParseEvent("verify_step", {"step": step_num, "total": total, "name": name}, line)
# Check result
m = self.CHECK_RE.match(clean)
if m:
glyph, msg = m.group(1), m.group(2)
status = {"": "passed", "": "failed", "": "skipped"}[glyph]
if status == "passed":
self.passed += 1
elif status == "failed":
self.failed += 1
else:
self.skipped += 1
check = {"glyph": glyph, "message": msg, "status": status, "hint": ""}
if self.current_step:
self.current_step["checks"].append(check)
return ParseEvent("verify_check", check, line)
# Hint
m = self.HINT_RE.match(clean)
if m and self.current_step and self.current_step["checks"]:
hint = m.group(1)
self.current_step["checks"][-1]["hint"] = hint
return ParseEvent("verify_hint", {"hint": hint}, line)
# Final verdict
if self.PASS_RE.match(clean):
return ParseEvent("verdict", {"status": Status.PASSED, "message": "All checks passed"}, line)
m = self.FAIL_RE.match(clean)
if m:
count = int(m.group(1))
return ParseEvent("verdict", {"status": Status.FAILED, "failed": count, "message": f"{count} check(s) failed"}, line)
return None
# ============================================================================
# verify-stress.sh parser (incl. NIAH)
# ============================================================================
class StressParser:
"""Parser for verify-stress.sh output."""
# [1/8] Long-context needle small rungs (10K / 30K) ...
PROBE_RE = re.compile(r"^\[(\d+)/8\] (.+?) \.\.\.")
# ✓ 10000 tokens: recalled '…' (got: …)
# △ 30000 tokens: recall MISS (…) — system OK, quality ceiling reached
TOKEN_RE = re.compile(r"^\s+([✓△✗⊘])\s+(\d+) tokens:")
# ✓ rung 1/6: target=95K actual=95K tok (36%) recalled '…' prefill=… t/s (…s) VRAM_free=…MB
RUNG_RE = re.compile(
r"^\s+([✓△✗⊘]) rung (\d+)/(\d+): target=(\d+)K"
)
# n_ctx=262000 ladder: 95000 → 125000 → ...
LADDER_RE = re.compile(r"^\s+n_ctx=(\d+)\s+ladder:")
# VRAM free (ladder start): 12345 MB
VRAM_FREE_RE = re.compile(r"^\s+VRAM free \(ladder start\): (\d+) MB")
# All stress / boundary checks passed.
PASS_RE = re.compile(r"^All stress")
# 3 stress check(s) failed.
FAIL_RE = re.compile(r"^(\d+) stress check")
def __init__(self):
self.probes: list[dict] = []
self.current_probe: Optional[dict] = None
self.niah_results: list[dict] = [] # rung/token results
self.ladder_info: dict = {}
def parse_line(self, line: str) -> Optional[ParseEvent]:
clean = strip_ansi(line)
# Probe header
m = self.PROBE_RE.match(clean)
if m:
probe_num, name = int(m.group(1)), m.group(2)
self.current_probe = {"num": probe_num, "name": name, "results": []}
self.probes.append(self.current_probe)
return ParseEvent("stress_probe", {"probe": probe_num, "name": name}, line)
# Token-level result (probes 1, 7)
m = self.TOKEN_RE.match(clean)
if m:
glyph, tokens = m.group(1), int(m.group(2))
status = {"": "passed", "": "partial", "": "failed", "": "skipped"}[glyph]
result = {"tokens": tokens, "status": status, "glyph": glyph}
self.niah_results.append(result)
if self.current_probe:
self.current_probe["results"].append(result)
return ParseEvent("niah_token", result, line)
# Rung-level result (probe 8 ceiling ladder)
m = self.RUNG_RE.match(clean)
if m:
glyph, rung, total, target_k = m.group(1), int(m.group(2)), int(m.group(3)), int(m.group(4))
status = {"": "passed", "": "partial", "": "failed", "": "skipped"}[glyph]
result = {"rung": rung, "total": total, "target_k": target_k, "status": status, "glyph": glyph}
self.niah_results.append(result)
if self.current_probe:
self.current_probe["results"].append(result)
return ParseEvent("niah_rung", result, line)
# Ladder info
m = self.LADDER_RE.match(clean)
if m:
self.ladder_info["n_ctx"] = int(m.group(1))
return ParseEvent("niah_ladder", {"n_ctx": int(m.group(1))}, line)
# VRAM free
m = self.VRAM_FREE_RE.match(clean)
if m:
self.ladder_info["vram_free_mb"] = int(m.group(1))
return ParseEvent("niah_vram", {"vram_free_mb": int(m.group(1))}, line)
# Verdicts
if self.PASS_RE.match(clean):
return ParseEvent("verdict", {"status": Status.PASSED, "message": "All stress checks passed"}, line)
m = self.FAIL_RE.match(clean)
if m:
count = int(m.group(1))
return ParseEvent("verdict", {"status": Status.FAILED, "failed": count, "message": f"{count} stress check(s) failed"}, line)
return None
# ============================================================================
# quality-test.sh parser
# ============================================================================
class QualityParser:
"""Parser for quality-test.sh output."""
# [1/15] TC-01 ✓ passed (2.3s)
# [7/15] TC-07 ✗ verifier_fail (3.1s)
SCENARIO_RE = re.compile(
r"^\s+\[(\d+)/(\d+)\] (\S+) ([✓✗]) (\w+) \(([\d.]+)s\)"
)
# Pack ID prefix mapping
PACK_PREFIXES = {
"TC": "toolcall-15",
"IF": "instructfollow-15",
"SO": "structoutput-15",
"DE": "dataextract-15",
"RM": "reasonmath-15",
"BF": "bugfind-15",
"CL": "cli-40",
"HA": "hermesagent-20",
"HE": "humaneval-plus-30",
"LC": "lcb-v6-30",
"GS": "gsm-symbolic-30",
"GP": "gpqa-diamond",
}
# TOTAL line
TOTAL_RE = re.compile(r"TOTAL.*?(\d+)/(\d+)")
def __init__(self):
self.scenarios: list[dict] = []
self.packs: dict[str, dict] = {} # pack_id -> {passed, total, scenarios}
self.total_passed: int = 0
self.total_count: int = 0
def _pack_from_prefix(self, scenario_id: str) -> str:
prefix = scenario_id[:2].upper()
return self.PACK_PREFIXES.get(prefix, "unknown")
def parse_line(self, line: str) -> Optional[ParseEvent]:
clean = strip_ansi(line)
# Scenario result
m = self.SCENARIO_RE.match(clean)
if m:
num, total, scenario_id, glyph, failure_mode, elapsed = (
int(m.group(1)), int(m.group(2)), m.group(3),
m.group(4), m.group(5), float(m.group(6))
)
passed = glyph == ""
pack_id = self._pack_from_prefix(scenario_id)
result = {
"num": num, "total": total,
"scenario_id": scenario_id,
"passed": passed,
"failure_mode": failure_mode if not passed else "",
"elapsed_s": elapsed,
"pack_id": pack_id,
}
self.scenarios.append(result)
# Update pack totals
pack = self.packs.setdefault(pack_id, {"passed": 0, "total": 0, "scenarios": []})
pack["total"] += 1
pack["scenarios"].append(result)
if passed:
pack["passed"] += 1
self.total_passed += 1
self.total_count += 1
return ParseEvent("quality_scenario", result, line)
# TOTAL line
m = self.TOTAL_RE.match(clean)
if m:
self.total_passed = int(m.group(1))
self.total_count = int(m.group(2))
return ParseEvent("quality_total", {
"passed": self.total_passed,
"total": self.total_count,
}, line)
return None
# ============================================================================
# soak-test.sh parser
# ============================================================================
class SoakParser:
"""Parser for soak-test.sh output."""
# [soak] mode=fresh sessions=20 turns=5 max_growth=200MiB timeout=1800s
MODE_RE = re.compile(r"^\[soak\] mode=(\w+) sessions=(\d+) turns=(\d+)")
# [soak] session 1/20
SESSION_RE = re.compile(r"^\[soak\] session (\d+)/(\d+)")
# [soak] turn 1/5: status=200 wall=5159ms ttft=481ms decode_tps=42.113 vram=43104MiB
TURN_RE = re.compile(
r"^\[soak\]\s+turn (\d+)/(\d+): status=(\d+) wall=(\d+)ms "
r"ttft=(\d+)ms decode_tps=([\d.]+) vram=(\d+)MiB"
)
# [soak] verdict PASS
VERDICT_RE = re.compile(r"^\[soak\]\s+verdict\s+(PASS|FAIL)")
# [soak] silent_empty 0 / 100 (0.0%)
METRIC_RE = re.compile(r"^\[soak\]\s+(silent_empty|tps_retention|p50_decode_tps|max_growth_mib)\s+(.+)")
# [soak] warm baseline after session 1: 43104 MiB
BASELINE_RE = re.compile(r"^\[soak\] warm baseline after session (\d+): (\d+) MiB")
def __init__(self):
self.mode: str = ""
self.total_sessions: int = 0
self.total_turns: int = 0
self.current_session: int = 0
self.turns: list[dict] = []
self.baseline_vram: int = 0
self.verdict: Optional[str] = None
self.metrics: dict = {}
def parse_line(self, line: str) -> Optional[ParseEvent]:
clean = strip_ansi(line)
# Mode/config line
m = self.MODE_RE.match(clean)
if m:
self.mode = m.group(1)
self.total_sessions = int(m.group(2))
self.total_turns = int(m.group(3))
return ParseEvent("soak_config", {
"mode": self.mode,
"sessions": self.total_sessions,
"turns": self.total_turns,
}, line)
# Session start
m = self.SESSION_RE.match(clean)
if m:
self.current_session = int(m.group(1))
return ParseEvent("soak_session", {
"session": self.current_session,
"total": int(m.group(2)),
}, line)
# Turn result
m = self.TURN_RE.match(clean)
if m:
turn_data = {
"turn": int(m.group(1)),
"total": int(m.group(2)),
"status": int(m.group(3)),
"wall_ms": int(m.group(4)),
"ttft_ms": int(m.group(5)),
"decode_tps": float(m.group(6)),
"vram_mib": int(m.group(7)),
"session": self.current_session,
}
self.turns.append(turn_data)
return ParseEvent("soak_turn", turn_data, line)
# Baseline
m = self.BASELINE_RE.match(clean)
if m:
self.baseline_vram = int(m.group(2))
return ParseEvent("soak_baseline", {"vram_mib": self.baseline_vram}, line)
# Verdict
m = self.VERDICT_RE.match(clean)
if m:
self.verdict = m.group(1)
status = Status.PASSED if self.verdict == "PASS" else Status.FAILED
return ParseEvent("verdict", {
"status": status,
"verdict": self.verdict,
}, line)
# Metrics
m = self.METRIC_RE.match(clean)
if m:
key, value = m.group(1), m.group(2)
self.metrics[key] = value
return ParseEvent("soak_metric", {"key": key, "value": value}, line)
return None
# ============================================================================
# rebench-full.sh parser (orchestrator)
# ============================================================================
class RebenchParser:
"""Parser for rebench-full.sh orchestrator output."""
# [verify-full] running…
STEP_RUNNING_RE = re.compile(r"^\[([\w-]+)\] running…")
# [verify-full] ✓ 96s — log: results/rebench/<tag>/verify-full.log
STEP_PASS_RE = re.compile(r"^\[([\w-]+)\] ✓ (\d+)s")
# [bench] ✗ 14s — failed (rc=1) — log: …
STEP_FAIL_RE = re.compile(r"^\[([\w-]+)\] ✗ (\d+)s — failed \(rc=(\d+)\)")
# [quality-full] skipped — 8-pack is opt-in
STEP_SKIP_RE = re.compile(r"^\[([\w-]+)\] skipped")
# report: results/rebench/<tag>/REPORT.md
REPORT_RE = re.compile(r"^\s+report:\s+(.+REPORT\.md)")
# artifacts: results/rebench/<tag>
ARTIFACTS_RE = re.compile(r"^\s+artifacts:\s+(.+)")
# rebench complete
COMPLETE_RE = re.compile(r"^\s*rebench complete")
STEP_ORDER = ["verify-full", "bench", "verify-stress", "quality-full", "quality-thinking", "soak"]
def __init__(self):
self.steps: dict[str, dict] = {}
self.current_step: Optional[str] = None
self.report_path: str = ""
self.artifacts_dir: str = ""
self.complete: bool = False
def parse_line(self, line: str) -> Optional[ParseEvent]:
clean = strip_ansi(line)
# Step running
m = self.STEP_RUNNING_RE.match(clean)
if m:
step = m.group(1)
self.current_step = step
self.steps[step] = {"status": Status.RUNNING, "elapsed_s": 0}
return ParseEvent("rebench_step_start", {"step": step}, line)
# Step passed
m = self.STEP_PASS_RE.match(clean)
if m:
step, elapsed = m.group(1), int(m.group(2))
self.steps[step] = {"status": Status.PASSED, "elapsed_s": elapsed}
self.current_step = None
return ParseEvent("rebench_step_done", {
"step": step, "status": Status.PASSED, "elapsed_s": elapsed,
}, line)
# Step failed
m = self.STEP_FAIL_RE.match(clean)
if m:
step, elapsed, rc = m.group(1), int(m.group(2)), int(m.group(3))
self.steps[step] = {"status": Status.FAILED, "elapsed_s": elapsed, "rc": rc}
self.current_step = None
return ParseEvent("rebench_step_done", {
"step": step, "status": Status.FAILED, "elapsed_s": elapsed, "rc": rc,
}, line)
# Step skipped
m = self.STEP_SKIP_RE.match(clean)
if m:
step = m.group(1)
self.steps[step] = {"status": Status.SKIPPED, "elapsed_s": 0}
return ParseEvent("rebench_step_done", {
"step": step, "status": Status.SKIPPED, "elapsed_s": 0,
}, line)
# Report path
m = self.REPORT_RE.match(clean)
if m:
self.report_path = m.group(1)
return ParseEvent("rebench_report", {"path": self.report_path}, line)
# Artifacts dir
m = self.ARTIFACTS_RE.match(clean)
if m:
self.artifacts_dir = m.group(1)
return ParseEvent("rebench_artifacts", {"dir": self.artifacts_dir}, line)
# Complete
if self.COMPLETE_RE.match(clean):
self.complete = True
return ParseEvent("rebench_complete", {}, line)
return None
def get_parser(test_type: TestType):
"""Factory to get the right parser for a test type."""
parsers = {
TestType.VERIFY: VerifyParser,
TestType.VERIFY_FULL: VerifyParser,
TestType.BENCH: BenchParser,
TestType.VERIFY_STRESS: StressParser,
TestType.QUALITY: QualityParser,
TestType.SOAK: SoakParser,
TestType.REBENCH: RebenchParser,
}
return parsers[test_type]()
@@ -0,0 +1,404 @@
"""Test runner — spawns and manages test subprocesses."""
from __future__ import annotations
import asyncio
import os
import signal
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Callable, Optional
from .detect import ServingTarget
from .parsers import ParseEvent, TestType, get_parser
@dataclass
class TestConfig:
"""Configuration for a test run."""
test_type: TestType
# Test-specific tunables
run_count: int = 5 # bench RUNS
warmups: int = 3 # bench WARMUPS
only: str = "both" # bench ONLY (both/narr/code)
enable_thinking: bool = False # bench/quality ENABLE_THINKING
pp: bool = False # bench PP
force_tokens: int = 0 # bench FORCE_TOKENS
skip_tools: bool = False # verify SKIP_TOOLS
run_bench: bool = False # verify-full --bench
skip_longctx: bool = False # verify-stress SKIP_LONGCTX
skip_tool_prefill: bool = False # verify-stress SKIP_TOOL_PREFILL
skip_ceiling: bool = False # verify-stress SKIP_CEILING
quality_tier: str = "medium" # quality --quick/--medium/--full/--reasoning
quality_pack: str = "" # quality --pack <id>
quality_no_sandboxed: bool = False
quality_sandboxed_only: bool = False
quality_sampling_server: bool = False
quality_repeat: int = 1
max_tokens: int = 0 # quality MAX_TOKENS (0 = default)
thinking_max_tokens: int = 0 # quality THINKING_MAX_TOKENS (0 = default)
soak_mode: str = "fresh" # soak --fresh/--continuous/--quick
soak_sessions: int = 10
soak_turns: int = 5
soak_max_growth: int = 200
soak_timeout: int = 1800
rebench_8pack: str = "" # "" or "off" or "on" or "both"
rebench_skip: list[str] = field(default_factory=list)
rebench_resume: bool = False
rebench_tag: str = ""
# External endpoint
external_url: str = ""
external_model: str = ""
external_engine: str = ""
@dataclass
class RunState:
"""State of an active test run."""
test_type: TestType
config: TestConfig
target: ServingTarget
started: float = 0.0
finished: float = 0.0
exit_code: Optional[int] = None
verdict: str = "" # passed/failed/unknown
events: list[ParseEvent] = field(default_factory=list)
log_lines: list[str] = field(default_factory=list)
error: str = ""
artifact_dir: str = ""
report_path: str = ""
@property
def elapsed_s(self) -> float:
end = self.finished or time.time()
return end - self.started if self.started else 0
@property
def is_running(self) -> bool:
return self.started > 0 and self.finished == 0
@property
def is_finished(self) -> bool:
return self.finished > 0
class TestRunner:
"""Manages spawning and tracking of test subprocesses."""
def __init__(self, repo_root: Path):
self.repo_root = repo_root
self.current_run: Optional[RunState] = None
self._process: Optional[asyncio.subprocess.Process] = None
self._cancel_event = asyncio.Event()
self._on_event: Optional[Callable[[ParseEvent], None]] = None
self._on_line: Optional[Callable[[str], None]] = None
self._on_complete: Optional[Callable[[RunState], None]] = None
self.history: list[RunState] = []
def set_callbacks(
self,
on_event: Optional[Callable[[ParseEvent], None]] = None,
on_line: Optional[Callable[[str], None]] = None,
on_complete: Optional[Callable[[RunState], None]] = None,
):
"""Set callback functions for events, lines, and completion."""
self._on_event = on_event
self._on_line = on_line
self._on_complete = on_complete
def _build_command(self, config: TestConfig) -> tuple[list[str], dict[str, str]]:
"""Build the command and environment for a test run."""
env = os.environ.copy()
env["PYTHONUNBUFFERED"] = "1"
args: list[str] = []
# Inject target
target = self.current_run.target if self.current_run else ServingTarget()
if target.url:
env["URL"] = target.url
if target.model:
env["MODEL"] = target.model
if target.container:
env["CONTAINER"] = target.container
# BENCHLOCAL_HERMES_RESOLVE_LOCALHOST for localhost quality/rebench
if target.is_localhost and config.test_type in (TestType.QUALITY, TestType.REBENCH):
env["BENCHLOCAL_HERMES_RESOLVE_LOCALHOST"] = "1"
# External endpoint mode (for rebench)
if config.external_url:
env["PREFLIGHT_NO_AUTODETECT"] = "1"
env["CONTAINER"] = "none"
match config.test_type:
case TestType.VERIFY:
args = ["bash", "scripts/verify.sh"]
if config.skip_tools:
env["SKIP_TOOLS"] = "1"
case TestType.VERIFY_FULL:
args = ["bash", "scripts/verify-full.sh"]
if config.skip_tools:
env["SKIP_TOOLS"] = "1"
if config.run_bench:
args.append("--bench")
case TestType.BENCH:
args = ["bash", "scripts/bench.sh"]
env["RUNS"] = str(config.run_count)
env["WARMUPS"] = str(config.warmups)
env["ONLY"] = config.only
if config.enable_thinking:
env["ENABLE_THINKING"] = "1"
if config.pp:
env["PP"] = "1"
if config.force_tokens:
env["FORCE_TOKENS"] = str(config.force_tokens)
case TestType.VERIFY_STRESS:
args = ["bash", "scripts/verify-stress.sh"]
if config.skip_longctx:
env["SKIP_LONGCTX"] = "1"
if config.skip_tool_prefill:
env["SKIP_TOOL_PREFILL"] = "1"
if config.skip_ceiling:
env["SKIP_CEILING"] = "1"
case TestType.QUALITY:
args = ["bash", "scripts/quality-test.sh"]
# Tier flag
args.append(f"--{config.quality_tier}")
if config.quality_pack:
args.extend(["--pack", config.quality_pack])
if config.quality_no_sandboxed:
args.append("--no-sandboxed")
if config.quality_sandboxed_only:
args.append("--sandboxed-only")
if config.enable_thinking:
args.append("--enable-thinking")
else:
args.append("--no-thinking")
if config.quality_sampling_server:
args.append("--sampling-from-server")
if config.quality_repeat > 1:
args.extend(["--repeat", str(config.quality_repeat)])
if config.max_tokens > 0:
env["MAX_TOKENS"] = str(config.max_tokens)
if config.thinking_max_tokens > 0:
env["THINKING_MAX_TOKENS"] = str(config.thinking_max_tokens)
case TestType.SOAK:
args = ["bash", "scripts/soak-test.sh"]
match config.soak_mode:
case "fresh":
args.append("--fresh")
case "continuous":
args.append("--continuous")
case "quick":
args.append("--quick")
env["SOAK_SESSIONS"] = str(config.soak_sessions)
env["SOAK_TURNS"] = str(config.soak_turns)
env["SOAK_MAX_GROWTH_MIB"] = str(config.soak_max_growth)
env["SOAK_TIMEOUT_S"] = str(config.soak_timeout)
case TestType.REBENCH:
args = ["bash", "scripts/rebench-full.sh"]
if config.rebench_8pack:
args.append(f"--with-8pack-thinking={config.rebench_8pack}")
if config.rebench_skip:
args.append(f"--skip={','.join(config.rebench_skip)}")
if config.rebench_resume:
args.append("--resume")
if config.rebench_tag:
args.append(f"--tag={config.rebench_tag}")
if config.external_url:
args.extend([
"--url", config.external_url,
"--model", config.external_model,
"--engine", config.external_engine or "other",
])
env["SOAK_SESSIONS"] = str(config.soak_sessions)
env["SOAK_TURNS"] = str(config.soak_turns)
if config.max_tokens > 0:
env["MAX_TOKENS"] = str(config.max_tokens)
if config.thinking_max_tokens > 0:
env["THINKING_MAX_TOKENS"] = str(config.thinking_max_tokens)
# Use stdbuf for line-buffered output
full_cmd = ["stdbuf", "-oL", "-eL"] + args
return full_cmd, env
async def start(self, config: TestConfig, target: ServingTarget) -> RunState:
"""Start a test run."""
state = RunState(
test_type=config.test_type,
config=config,
target=target,
started=time.time(),
)
self.current_run = state
self._cancel_event.clear()
cmd, env = self._build_command(config)
try:
self._process = await asyncio.create_subprocess_exec(
*cmd,
cwd=str(self.repo_root),
env=env,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT, # Merge stderr into stdout
start_new_session=True, # Own process group for signal delivery
)
except Exception as e:
state.error = str(e)
state.finished = time.time()
state.exit_code = -1
state.verdict = "failed"
if self._on_complete:
self._on_complete(state)
return state
# Start the reader task
asyncio.create_task(self._read_output(state))
return state
async def _read_output(self, state: RunState):
"""Read subprocess output and parse it."""
parser = get_parser(state.test_type)
proc = self._process
try:
while True:
if self._cancel_event.is_set():
break
try:
line_bytes = await asyncio.wait_for(
proc.stdout.readline(),
timeout=1.0,
)
except asyncio.TimeoutError:
# Normal — retry
continue
if not line_bytes:
# EOF
break
line = line_bytes.decode("utf-8", errors="replace").rstrip("\n")
state.log_lines.append(line)
# Notify line callback
if self._on_line:
self._on_line(line)
# Parse for structured events
event = parser.parse_line(line)
if event:
state.events.append(event)
# Extract artifacts/report from rebench
if event.event_type == "rebench_report":
state.report_path = event.data.get("path", "")
elif event.event_type == "rebench_artifacts":
state.artifact_dir = event.data.get("dir", "")
elif event.event_type == "verdict":
state.verdict = "passed" if event.data.get("status") == "passed" else "failed"
if self._on_event:
self._on_event(event)
except asyncio.CancelledError:
pass
except Exception as e:
state.error = str(e)
# Wait for process exit
try:
await asyncio.wait_for(proc.wait(), timeout=10)
except asyncio.TimeoutError:
proc.kill()
await proc.wait()
state.exit_code = proc.returncode
state.finished = time.time()
# Determine verdict from exit code if not set by parser
if not state.verdict:
if state.exit_code == 0:
state.verdict = "passed"
else:
state.verdict = "failed"
self.history.append(state)
self.current_run = None
self._process = None
if self._on_complete:
self._on_complete(state)
async def cancel(self) -> list[str]:
"""Cancel the current run. Returns list of orphaned container names if any."""
if not self._process:
return []
was_quality = (self.current_run and
self.current_run.test_type == TestType.QUALITY)
self._cancel_event.set()
proc = self._process
# SIGINT first (graceful)
try:
pgid = os.getpgid(proc.pid)
os.killpg(pgid, signal.SIGINT)
except (ProcessLookupError, OSError):
pass
# Wait up to 5s
try:
await asyncio.wait_for(proc.wait(), timeout=5)
except asyncio.TimeoutError:
# SIGTERM
try:
pgid = os.getpgid(proc.pid)
os.killpg(pgid, signal.SIGTERM)
except (ProcessLookupError, OSError):
pass
# Wait up to 5s more
try:
await asyncio.wait_for(proc.wait(), timeout=5)
except asyncio.TimeoutError:
# SIGKILL
try:
pgid = os.getpgid(proc.pid)
os.killpg(pgid, signal.SIGKILL)
except (ProcessLookupError, OSError):
pass
await proc.wait()
# Check for orphaned benchlocal containers (known issue with quality tests)
orphans = []
if was_quality:
orphans = await self._check_benchlocal_orphans()
return orphans
async def _check_benchlocal_orphans(self) -> list[str]:
"""Check for orphaned benchlocal containers that may be squatting ports."""
try:
proc = await asyncio.create_subprocess_exec(
"docker", "ps", "--format", "{{.Names}}",
"--filter", "name=benchlocal-",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=5)
containers = [name.strip() for name in stdout.decode().split("\n") if name.strip()]
return containers
except Exception:
return []
@@ -0,0 +1 @@
"""Custom Textual widgets for the test console."""
@@ -0,0 +1,344 @@
"""History view — browse past runs and existing artifacts."""
from __future__ import annotations
import json
import time
from pathlib import Path
from typing import Optional
from textual.app import ComposeResult
from textual.containers import Grid, Horizontal, Vertical
from textual.screen import ModalScreen
from textual.widgets import Button, Label, RichLog, Static, DataTable
from ..parsers import TestType
class RunRecord:
"""A single run record from history."""
def __init__(self, data: dict):
self.test = data.get("test", "unknown")
self.model = data.get("model", "")
self.url = data.get("url", "")
self.slug = data.get("slug", "")
self.started = data.get("started", 0)
self.finished = data.get("finished", 0)
self.exit_code = data.get("exit_code", -1)
self.verdict = data.get("verdict", "unknown")
self.elapsed_s = data.get("elapsed_s", 0)
self.artifact_dir = data.get("artifact_dir", "")
self.report_path = data.get("report_path", "")
self.power_cap_w = data.get("power_cap_w")
self.log_path = data.get("log_path", "")
@property
def timestamp_str(self) -> str:
if self.started:
return time.strftime("%Y-%m-%d %H:%M", time.localtime(self.started))
return "unknown"
@property
def status_glyph(self) -> str:
if self.verdict == "passed":
return "[green]✓[/green]"
elif self.verdict == "failed":
return "[red]✗[/red]"
return "[dim]?[/dim]"
class RunDetailScreen(ModalScreen):
"""Show details of a single run with report/log content."""
DEFAULT_CSS = """
RunDetailScreen {
align: center middle;
}
RunDetailScreen > Vertical {
width: 90%;
height: 90%;
border: thick $primary;
background: $surface;
padding: 1 2;
}
RunDetailScreen .detail-title {
text-style: bold;
color: $accent;
text-align: center;
height: 1;
}
RunDetailScreen #detail-content {
height: 1fr;
}
RunDetailScreen #button-bar {
height: 3;
}
RunDetailScreen Button {
width: 1fr;
}
"""
BINDINGS = [
("escape", "dismiss", "Close"),
]
def __init__(self, record: RunRecord, repo_root: Path):
super().__init__()
self.record = record
self.repo_root = repo_root
self._current_view = "summary" # summary, report, log
def compose(self) -> ComposeResult:
with Vertical():
yield Label(f"Run Details: {self.record.test}", classes="detail-title")
yield RichLog(id="detail-content")
with Horizontal(id="button-bar"):
yield Button("Summary", variant="primary", id="btn-summary")
if self.record.report_path:
yield Button("Report", variant="default", id="btn-report")
if self.record.log_path:
yield Button("Log", variant="default", id="btn-log")
yield Button("Close", variant="default", id="btn-close")
def on_mount(self) -> None:
self._show_summary()
def _show_summary(self) -> None:
"""Display run summary."""
self._current_view = "summary"
content = self.query_one("#detail-content", RichLog)
content.clear()
r = self.record
content.write(f"[bold]Test:[/bold] {r.test}")
content.write(f"[bold]Model:[/bold] {r.model}")
content.write(f"[bold]URL:[/bold] {r.url}")
content.write(f"[bold]Slug:[/bold] {r.slug or '(none)'}")
content.write(f"[bold]Started:[/bold] {r.timestamp_str}")
content.write(f"[bold]Elapsed:[/bold] {r.elapsed_s:.1f}s")
content.write(f"[bold]Exit code:[/bold] {r.exit_code}")
content.write(f"[bold]Verdict:[/bold] {r.status_glyph} {r.verdict}")
if r.power_cap_w:
content.write(f"[bold]Power cap:[/bold] {r.power_cap_w:.0f}W")
if r.artifact_dir:
content.write(f"\n[bold]Artifacts:[/bold] {r.artifact_dir}")
if r.report_path:
content.write(f"[bold]Report:[/bold] {r.report_path}")
if r.log_path:
content.write(f"[bold]Log:[/bold] {r.log_path}")
def _show_report(self) -> None:
"""Load and display the report file."""
if not self.record.report_path:
return
self._current_view = "report"
content = self.query_one("#detail-content", RichLog)
content.clear()
report_path = Path(self.record.report_path)
# Make relative to repo root if needed
if not report_path.is_absolute():
report_path = self.repo_root / report_path
if report_path.exists():
try:
text = report_path.read_text()
# Render markdown-ish content
for line in text.split("\n"):
if line.startswith("# "):
content.write(f"[bold cyan]{line[2:]}[/bold cyan]")
elif line.startswith("## "):
content.write(f"[bold]{line[3:]}[/bold]")
elif line.startswith("### "):
content.write(f"[bold yellow]{line[4:]}[/bold yellow]")
elif line.startswith("- "):
content.write(f"{line[2:]}")
elif line.startswith("```"):
content.write("[dim]" + line + "[/dim]")
else:
content.write(line)
except Exception as e:
content.write(f"[red]Error reading report: {e}[/red]")
else:
content.write(f"[red]Report file not found: {report_path}[/red]")
def _show_log(self) -> None:
"""Load and display the log file."""
if not self.record.log_path:
return
self._current_view = "log"
content = self.query_one("#detail-content", RichLog)
content.clear()
log_path = Path(self.record.log_path)
if not log_path.is_absolute():
log_path = self.repo_root / log_path
if log_path.exists():
try:
text = log_path.read_text()
# Show last 500 lines
lines = text.split("\n")
if len(lines) > 500:
content.write(f"[dim]... showing last 500 of {len(lines)} lines ...[/dim]\n")
lines = lines[-500:]
for line in lines:
content.write(line)
except Exception as e:
content.write(f"[red]Error reading log: {e}[/red]")
else:
content.write(f"[red]Log file not found: {log_path}[/red]")
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "btn-close":
self.app.pop_screen()
elif event.button.id == "btn-summary":
self._show_summary()
elif event.button.id == "btn-report":
self._show_report()
elif event.button.id == "btn-log":
self._show_log()
def action_dismiss(self) -> None:
self.app.pop_screen()
class HistoryScreen(ModalScreen):
"""Browse past runs from ~/.local/state and results/ artifacts."""
DEFAULT_CSS = """
HistoryScreen {
align: center middle;
}
HistoryScreen > Vertical {
width: 90%;
height: 80%;
border: thick $primary;
background: $surface;
padding: 1 2;
}
HistoryScreen .history-title {
text-style: bold;
color: $accent;
text-align: center;
margin-bottom: 1;
}
HistoryScreen DataTable {
height: 1fr;
}
HistoryScreen .history-hint {
text-align: center;
color: $text-muted;
height: 1;
}
"""
BINDINGS = [
("escape", "dismiss", "Close"),
]
def __init__(self, state_dir: Path, repo_root: Path):
super().__init__()
self.state_dir = state_dir
self.repo_root = repo_root
self.records: list[RunRecord] = []
def compose(self) -> ComposeResult:
with Vertical():
yield Label("Run History", classes="history-title")
yield DataTable(id="history-table", cursor_type="row")
yield Label("Press Enter or click a row to view details", classes="history-hint")
yield Button("Close", variant="default", id="btn-close")
def on_mount(self) -> None:
self._load_records()
table = self.query_one("#history-table", DataTable)
table.add_columns("Time", "Test", "Model", "Verdict", "Elapsed", "Power")
for i, record in enumerate(self.records):
table.add_row(
record.timestamp_str,
record.test,
record.model[:20],
record.status_glyph,
f"{record.elapsed_s:.0f}s",
f"{record.power_cap_w:.0f}W" if record.power_cap_w else "-",
key=str(i),
)
# Focus the table
table.focus()
def _load_records(self) -> None:
"""Load run records from state dir and results/ artifacts."""
# Load from state dir
runs_dir = self.state_dir / "runs"
if runs_dir.exists():
for path in sorted(runs_dir.glob("*.json"), reverse=True):
try:
data = json.loads(path.read_text())
# Infer log path from artifact dir
if "artifact_dir" in data and data["artifact_dir"]:
artifact_dir = Path(data["artifact_dir"])
test_name = data.get("test", "")
log_path = artifact_dir / f"{test_name}.log"
if log_path.exists():
data["log_path"] = str(log_path)
self.records.append(RunRecord(data))
except Exception:
pass
# Discover results/ artifacts
results_dir = self.repo_root / "results"
if results_dir.exists():
# Rebench results
for tag_dir in sorted(results_dir.glob("rebench/*"), reverse=True):
if (tag_dir / "REPORT.md").exists():
# Find log files in the directory
log_files = list(tag_dir.glob("*.log"))
log_path = str(log_files[0]) if log_files else ""
self.records.append(RunRecord({
"test": "rebench-full",
"model": tag_dir.name.split("-")[0] if "-" in tag_dir.name else tag_dir.name,
"started": tag_dir.stat().st_mtime,
"verdict": "passed",
"artifact_dir": str(tag_dir),
"report_path": str(tag_dir / "REPORT.md"),
"log_path": log_path,
}))
# Quality results
for qdir in sorted(results_dir.glob("quality"), reverse=True):
for json_file in qdir.glob("quality-*.json"):
try:
data = json.loads(json_file.read_text())
self.records.append(RunRecord({
"test": "quality",
"model": data.get("model", "unknown"),
"started": json_file.stat().st_mtime,
"verdict": "passed" if data.get("totals", {}).get("score", 0) >= 0.8 else "failed",
"artifact_dir": str(json_file.parent),
"log_path": "",
}))
except Exception:
pass
# Sort by time descending
self.records.sort(key=lambda r: r.started, reverse=True)
def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
"""Handle row selection in the table."""
try:
row_key = str(event.row_key.value)
idx = int(row_key)
if 0 <= idx < len(self.records):
record = self.records[idx]
self.app.push_screen(RunDetailScreen(record, self.repo_root))
except (ValueError, IndexError):
pass
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "btn-close":
self.app.pop_screen()
def action_dismiss(self) -> None:
self.app.pop_screen()
@@ -0,0 +1,309 @@
"""Live output pane — shows structured progress + raw log."""
from __future__ import annotations
import time
from typing import Optional
from textual.app import ComposeResult
from textual.containers import Vertical
from textual.reactive import reactive
from textual.widget import Widget
from textual.widgets import Label, RichLog, Static
from ..parsers import (
ParseEvent,
TestType,
BenchParser,
VerifyParser,
StressParser,
QualityParser,
SoakParser,
RebenchParser,
)
class StructuredHeader(Static):
"""Structured progress display above the raw log."""
DEFAULT_CSS = """
StructuredHeader {
width: 100%;
height: auto;
min-height: 3;
max-height: 12;
padding: 0 1;
background: $boost;
}
StructuredHeader .header-title {
text-style: bold;
color: $accent;
}
StructuredHeader .progress-bar {
height: 1;
}
"""
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._lines: list[str] = []
def update_from_event(self, event: ParseEvent, test_type: TestType) -> None:
"""Update the structured display from a parsed event."""
# Build display based on test type
match test_type:
case TestType.BENCH:
self._update_bench(event)
case TestType.VERIFY | TestType.VERIFY_FULL:
self._update_verify(event)
case TestType.VERIFY_STRESS:
self._update_stress(event)
case TestType.QUALITY:
self._update_quality(event)
case TestType.SOAK:
self._update_soak(event)
case TestType.REBENCH:
self._update_rebench(event)
def _update_bench(self, event: ParseEvent) -> None:
if event.event_type == "bench_section":
section = event.data.get("section", "").upper()
self._lines.append(f"[bold cyan]═══ {section} ═══[/bold cyan]")
elif event.event_type == "bench_run":
run_type = event.data.get("type", "")
run_num = event.data.get("run", 0)
decode_tps = event.data.get("decode_tps", 0)
ttft = event.data.get("ttft_ms", 0)
if run_type == "run":
self._lines.append(f" run-{run_num} decode [green]{decode_tps:.1f}[/green] TPS ttft {ttft}ms")
elif event.event_type == "summary_metric":
metric = event.data.get("metric", "")
mean = event.data.get("mean", 0)
cv = event.data.get("cv", 0)
self._lines.append(f" [bold]{metric}[/bold] mean=[cyan]{mean:.1f}[/cyan] CV={cv:.1f}%")
self._lines = self._lines[-20:] # Keep last 20 lines
self._refresh_display()
def _update_verify(self, event: ParseEvent) -> None:
if event.event_type == "verify_step":
step = event.data.get("step", 0)
total = event.data.get("total", 0)
name = event.data.get("name", "")
self._lines.append(f"[cyan][{step}/{total}][/cyan] {name}")
elif event.event_type == "verify_check":
glyph = event.data.get("glyph", "")
msg = event.data.get("message", "")
color = {"": "green", "": "red", "": "yellow"}.get(glyph, "")
self._lines.append(f" [{color}]{glyph}[/{color}] {msg}")
self._lines = self._lines[-20:]
elif event.event_type == "verdict":
status = event.data.get("status", "")
msg = event.data.get("message", "")
if status == "passed":
self._lines.append(f"[bold green]✓ {msg}[/bold green]")
else:
self._lines.append(f"[bold red]✗ {msg}[/bold red]")
self._refresh_display()
def _update_stress(self, event: ParseEvent) -> None:
if event.event_type == "stress_probe":
probe = event.data.get("probe", 0)
name = event.data.get("name", "")
self._lines.append(f"[cyan][{probe}/8][/cyan] {name}")
elif event.event_type == "niah_rung":
rung = event.data.get("rung", 0)
total = event.data.get("total", 0)
target_k = event.data.get("target_k", 0)
glyph = event.data.get("glyph", "")
status = event.data.get("status", "")
color = {"passed": "green", "partial": "yellow", "failed": "red", "skipped": "dim"}.get(status, "")
self._lines.append(f" rung {rung}/{total} [{color}]{glyph} {target_k}K[/{color}]")
self._lines = self._lines[-20:]
elif event.event_type == "niah_token":
tokens = event.data.get("tokens", 0)
glyph = event.data.get("glyph", "")
status = event.data.get("status", "")
color = {"passed": "green", "partial": "yellow", "failed": "red", "skipped": "dim"}.get(status, "")
self._lines.append(f" [{color}]{glyph}[/] {tokens:,} tokens")
elif event.event_type == "verdict":
status = event.data.get("status", "")
msg = event.data.get("message", "")
color = "green" if status == "passed" else "red"
self._lines.append(f"[bold {color}]{msg}[/bold {color}]")
self._refresh_display()
def _update_quality(self, event: ParseEvent) -> None:
if event.event_type == "quality_scenario":
num = event.data.get("num", 0)
total = event.data.get("total", 0)
scenario_id = event.data.get("scenario_id", "")
passed = event.data.get("passed", False)
elapsed = event.data.get("elapsed_s", 0)
glyph = "" if passed else ""
color = "green" if passed else "red"
self._lines.append(f" [{num}/{total}] {scenario_id} [{color}]{glyph}[/{color}] ({elapsed:.1f}s)")
self._lines = self._lines[-25:]
elif event.event_type == "quality_total":
passed = event.data.get("passed", 0)
total = event.data.get("total", 0)
pct = (passed / total * 100) if total > 0 else 0
self._lines.append(f"[bold]TOTAL: {passed}/{total} ({pct:.0f}%)[/bold]")
self._refresh_display()
def _update_soak(self, event: ParseEvent) -> None:
if event.event_type == "soak_session":
session = event.data.get("session", 0)
total = event.data.get("total", 0)
self._lines.append(f"[cyan]session {session}/{total}[/cyan]")
elif event.event_type == "soak_turn":
turn = event.data.get("turn", 0)
total = event.data.get("total", 0)
tps = event.data.get("decode_tps", 0)
vram = event.data.get("vram_mib", 0)
self._lines.append(f" turn {turn}/{total} [green]{tps:.1f}[/green] TPS {vram}MiB")
self._lines = self._lines[-20:]
elif event.event_type == "verdict":
verdict = event.data.get("verdict", "")
color = "green" if verdict == "PASS" else "red"
self._lines.append(f"[bold {color}]verdict: {verdict}[/bold {color}]")
elif event.event_type == "soak_metric":
key = event.data.get("key", "")
value = event.data.get("value", "")
self._lines.append(f" {key}: {value}")
self._refresh_display()
def _update_rebench(self, event: ParseEvent) -> None:
if event.event_type == "rebench_step_start":
step = event.data.get("step", "")
self._lines.append(f"[cyan]▶ {step} running…[/cyan]")
elif event.event_type == "rebench_step_done":
step = event.data.get("step", "")
status = event.data.get("status", "")
elapsed = event.data.get("elapsed_s", 0)
if status == "passed":
self._lines.append(f"[green]✓ {step} {elapsed}s[/green]")
elif status == "failed":
rc = event.data.get("rc", 0)
self._lines.append(f"[red]✗ {step} {elapsed}s (rc={rc})[/red]")
elif status == "skipped":
self._lines.append(f"[yellow]⊘ {step} skipped[/yellow]")
elif event.event_type == "rebench_complete":
self._lines.append("[bold green]═══ rebench complete ═══[/bold green]")
elif event.event_type == "rebench_report":
path = event.data.get("path", "")
self._lines.append(f" report: {path}")
self._refresh_display()
def _refresh_display(self) -> None:
"""Refresh the widget display."""
try:
content = "\n".join(self._lines[-25:]) if self._lines else "Waiting for output..."
self.update(content)
except Exception:
pass
def clear(self) -> None:
"""Clear the structured header."""
self._lines = []
self.update("")
class LivePane(Static):
"""Right pane showing structured progress + raw log."""
DEFAULT_CSS = """
LivePane {
width: 1fr;
height: 1fr;
border: solid $primary;
}
LivePane .live-title {
text-style: bold;
color: $accent;
dock: top;
padding: 0 1;
height: 1;
}
LivePane StructuredHeader {
dock: top;
height: auto;
max-height: 12;
}
LivePane RichLog {
height: 1fr;
overflow-y: auto;
}
"""
current_test: reactive[Optional[TestType]] = reactive(None)
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._follow = True
self._run_start_time = None
def compose(self) -> ComposeResult:
yield Label("Live", classes="live-title")
yield StructuredHeader(id="structured-header")
yield RichLog(id="live-log", wrap=True, highlight=True, markup=True)
def on_mount(self) -> None:
log = self.query_one("#live-log", RichLog)
log.write("[dim]Ready. Select a test and press Enter to run.[/dim]")
def append_line(self, line: str) -> None:
"""Append a raw log line."""
try:
log = self.query_one("#live-log", RichLog)
log.write(line)
if self._follow:
log.scroll_end(animate=False)
except Exception:
pass
def process_event(self, event: ParseEvent, test_type: TestType) -> None:
"""Process a parsed event and update the structured header."""
try:
header = self.query_one("#structured-header", StructuredHeader)
header.update_from_event(event, test_type)
except Exception:
pass
def clear_log(self) -> None:
"""Clear the log and structured header."""
try:
log = self.query_one("#live-log", RichLog)
log.clear()
header = self.query_one("#structured-header", StructuredHeader)
header.clear()
except Exception:
pass
def set_run_header(self, test_type: TestType, model: str, elapsed: str = "") -> None:
"""Set the header for a new run."""
self.current_test = test_type
self._run_start_time = time.time()
self.clear_log()
try:
log = self.query_one("#live-log", RichLog)
log.write(f"[bold cyan]▶ {test_type.value}[/bold cyan] model={model} {elapsed}")
log.write("" * 60)
except Exception:
pass
def update_elapsed_timer(self) -> None:
"""Update the elapsed timer display."""
if not self._run_start_time:
return
elapsed_s = time.time() - self._run_start_time
minutes = int(elapsed_s) // 60
seconds = int(elapsed_s) % 60
try:
title = self.query_one(".live-title", Label)
title.update(f"Live [dim]elapsed {minutes:02d}:{seconds:02d}[/dim]")
except Exception:
pass
def toggle_follow(self) -> None:
"""Toggle log follow/scroll-lock."""
self._follow = not self._follow
@@ -0,0 +1,125 @@
"""Manual target override screen."""
from __future__ import annotations
from typing import Optional
from textual.app import ComposeResult
from textual.containers import Vertical
from textual.screen import ModalScreen
from textual.widgets import Button, Input, Label, Static, DataTable
from ..detect import ServingTarget, detect_from_registry
class ManualTargetScreen(ModalScreen[Optional[ServingTarget]]):
"""Manual target override — pick registry slug or enter external URL."""
DEFAULT_CSS = """
ManualTargetScreen {
align: center middle;
}
ManualTargetScreen > Vertical {
width: 80;
height: 40;
border: thick $primary;
background: $surface;
padding: 1 2;
}
ManualTargetScreen .manual-title {
text-style: bold;
color: $accent;
text-align: center;
margin-bottom: 1;
}
ManualTargetScreen .section-title {
text-style: bold;
margin-top: 1;
}
ManualTargetScreen DataTable {
height: 10;
}
ManualTargetScreen Input {
margin: 0 0 1 0;
}
"""
BINDINGS = [
("escape", "cancel", "Cancel"),
]
def __init__(self, repo_root: str, variants: list[dict]):
super().__init__()
self.repo_root = repo_root
self.variants = variants
def compose(self) -> ComposeResult:
with Vertical():
yield Label("Manual Target Override", classes="manual-title")
yield Label("Option 1: Select from registry", classes="section-title")
yield DataTable(id="registry-table")
yield Label("Option 2: External endpoint", classes="section-title")
yield Label("URL:")
yield Input(placeholder="http://192.168.1.50:8887", id="input-url")
yield Label("Model name:")
yield Input(placeholder="qwen3.6-27b-autoround", id="input-model")
yield Label("Engine (vllm/llamacpp/sglang/other):")
yield Input(placeholder="vllm", id="input-engine")
yield Button("Use selected", variant="primary", id="btn-registry")
yield Button("Use external", variant="primary", id="btn-external")
yield Button("Cancel", variant="default", id="btn-cancel")
def on_mount(self) -> None:
table = self.query_one("#registry-table", DataTable)
table.add_columns("Slug", "Model", "Engine", "Port", "Status")
for v in self.variants[:50]: # Limit to 50 rows
table.add_row(
v.get("slug", ""),
v.get("model", "")[:20],
v.get("engine", ""),
str(v.get("port", "")),
v.get("status", ""),
)
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "btn-registry":
table = self.query_one("#registry-table", DataTable)
if table.cursor_row is not None and table.cursor_row < len(self.variants):
v = self.variants[table.cursor_row]
target = ServingTarget(
url=f"http://localhost:{v.get('port', 8020)}",
model=v.get("model", ""),
slug=v.get("slug", ""),
engine=v.get("engine", ""),
kv_format=v.get("kvcalc_key", ""),
status=v.get("status", ""),
host_port=v.get("port", 8020),
container=v.get("container", ""), # Set container identity
health="serving",
)
self.dismiss(target)
elif event.button.id == "btn-external":
url = self.query_one("#input-url", Input).value
model = self.query_one("#input-model", Input).value
engine = self.query_one("#input-engine", Input).value or "other"
if not url or not model:
self.notify("URL and model are required", severity="error")
return
target = ServingTarget(
url=url,
model=model,
engine=engine,
container="none",
health="serving",
)
self.dismiss(target)
elif event.button.id == "btn-cancel":
self.dismiss(None)
def action_cancel(self) -> None:
self.dismiss(None)
@@ -0,0 +1,131 @@
"""Target status pane widget — shows detected model, endpoint, GPU stats."""
from __future__ import annotations
from textual.app import ComposeResult
from textual.containers import Vertical
from textual.reactive import reactive
from textual.widget import Widget
from textual.widgets import Label, Static
from ..detect import ServingTarget
STATUS_GLYPHS = {
"serving": "● serving",
"unreachable": "○ unreachable",
"multiple": "⚠ multiple containers",
"unknown": "? unknown",
}
STATUS_COLORS = {
"serving": "green",
"unreachable": "red",
"multiple": "yellow",
"unknown": "dim",
}
REGISTRY_STATUS_GLYPHS = {
"production": "",
"caveats": "⚠️",
"experimental": "🧪",
"incubating": "🐣",
"preview": "👁️",
"upstream-gated": "⏸️",
"deprecated": "🗑️",
}
class TargetPane(Static):
"""Left-top pane showing serving target info."""
target: reactive[ServingTarget | None] = reactive(None)
DEFAULT_CSS = """
TargetPane {
width: 100%;
height: auto;
min-height: 8;
border: solid $primary;
padding: 0 1;
}
TargetPane .pane-title {
text-style: bold;
color: $accent;
dock: top;
}
TargetPane .target-row {
height: 1;
}
TargetPane .target-label {
color: $text-muted;
}
TargetPane .no-target {
color: $warning;
text-style: italic;
}
"""
def compose(self) -> ComposeResult:
yield Label("Target", classes="pane-title")
yield Label("Detecting...", classes="target-row", id="target-model")
yield Label("", classes="target-row", id="target-slug")
yield Label("", classes="target-row", id="target-engine")
yield Label("", classes="target-row", id="target-url")
yield Label("", classes="target-row", id="target-gpu0")
yield Label("", classes="target-row", id="target-gpu1")
def watch_target(self, target: ServingTarget | None) -> None:
"""Update display when target changes."""
if target is None:
self._show_no_target()
return
model_widget = self.query_one("#target-model", Label)
slug_widget = self.query_one("#target-slug", Label)
engine_widget = self.query_one("#target-engine", Label)
url_widget = self.query_one("#target-url", Label)
gpu0_widget = self.query_one("#target-gpu0", Label)
gpu1_widget = self.query_one("#target-gpu1", Label)
if not target.model:
self._show_no_target()
return
# Model line
status_glyph = STATUS_GLYPHS.get(target.health, STATUS_GLYPHS["unknown"])
model_widget.update(f"Model {target.model}")
# Slug + registry status
if target.slug:
reg_glyph = REGISTRY_STATUS_GLYPHS.get(target.status, "")
slug_widget.update(f"Slug {target.slug} {reg_glyph} {target.status}")
else:
slug_widget.update("")
# Engine line
tp_str = f"TP {target.tp}" if target.tp else ""
kv_str = f"KV {target.kv_format}" if target.kv_format else ""
engine_widget.update(f"Engine {target.engine} {tp_str} {kv_str}".strip())
# URL + health
color = STATUS_COLORS.get(target.health, "dim")
url_widget.update(f"URL :{target.host_port} [{color}]{status_glyph}[/{color}]")
# GPU lines
if target.gpus:
for i, gpu in enumerate(target.gpus[:2]):
widget = gpu0_widget if i == 0 else gpu1_widget
widget.update(
f"GPU{gpu.index} {gpu.mem_used_mib}/{gpu.mem_total_mib}M "
f"{gpu.utilization}% {gpu.power_draw_w:.0f}W/{gpu.power_limit_w:.0f}W "
f"{gpu.temp_c}°C"
)
else:
gpu0_widget.update("")
gpu1_widget.update("")
def _show_no_target(self) -> None:
model_widget = self.query_one("#target-model", Label)
model_widget.update("[yellow]No model serving — run gpu-mode <mode>[/yellow]")
for widget_id in ("#target-slug", "#target-engine", "#target-url", "#target-gpu0", "#target-gpu1"):
self.query_one(widget_id, Label).update("")
@@ -0,0 +1,171 @@
"""Test menu pane — lists available tests and their status."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Optional
from textual.app import ComposeResult
from textual.message import Message
from textual.reactive import reactive
from textual.widget import Widget
from textual.widgets import Label, ListItem, ListView, Static
from ..parsers import TestType
@dataclass
class TestEntry:
"""A test menu entry."""
test_type: TestType
display_name: str
duration_hint: str
description: str = ""
# The test catalog from spec Section 3
TEST_CATALOG = [
TestEntry(TestType.VERIFY, "Smoke (verify)", "~15s", "Quick server reachability check"),
TestEntry(TestType.VERIFY_FULL, "Functional (verify-full)", "~2min", "Full functional test suite"),
TestEntry(TestType.BENCH, "Speed bench (TPS)", "~5min", "Throughput benchmark"),
TestEntry(TestType.VERIFY_STRESS, "Stress / NIAH", "~15min", "Long-context + boundary tests"),
TestEntry(TestType.QUALITY, "Quality packs", "5-90min", "Behavioral quality testing"),
TestEntry(TestType.SOAK, "Soak / stability", "~20min", "Long-running stability test"),
TestEntry(TestType.REBENCH, "★ FULL rebench (macro)", "~45min", "Complete test pipeline"),
]
STATUS_GLYPHS = {
"idle": "",
"running": "",
"passed": "",
"failed": "",
"skipped": "",
"queued": "",
}
class TestMenuPane(Static):
"""Left-bottom pane showing the test menu."""
selected_index: reactive[int] = reactive(0)
DEFAULT_CSS = """
TestMenuPane {
width: 100%;
height: 1fr;
border: solid $primary;
padding: 0 1;
}
TestMenuPane .pane-title {
text-style: bold;
color: $accent;
dock: top;
}
TestMenuPane ListView {
height: 1fr;
}
TestMenuPane ListView > ListItem {
height: 2;
padding: 0 1;
}
TestMenuPane ListView > ListItem.--highlight {
background: $boost;
}
TestMenuPane .test-status {
width: 3;
}
TestMenuPane .test-name {
width: 1fr;
}
TestMenuPane .test-duration {
width: 8;
text-align: right;
color: $text-muted;
}
"""
class TestSelected(Message):
"""Fired when a test is selected."""
def __init__(self, entry: TestEntry) -> None:
self.entry = entry
super().__init__()
class TestActivated(Message):
"""Fired when a test is activated (Enter pressed)."""
def __init__(self, entry: TestEntry) -> None:
self.entry = entry
super().__init__()
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._entries = TEST_CATALOG
self._statuses: dict[TestType, str] = {t.test_type: "idle" for t in TEST_CATALOG}
def compose(self) -> ComposeResult:
yield Label("Tests", classes="pane-title")
with ListView(id="test-list"):
for entry in self._entries:
with ListItem():
yield Label(self._format_entry(entry), classes="test-entry")
def _format_entry(self, entry: TestEntry) -> str:
status = self._statuses.get(entry.test_type, "idle")
glyph = STATUS_GLYPHS.get(status, "")
color = {
"idle": "",
"running": "[cyan]",
"passed": "[green]",
"failed": "[red]",
"skipped": "[yellow]",
"queued": "[dim]",
}.get(status, "")
end_color = f"[/{color.rstrip(']')}]" if color else ""
# Strip brackets for rich markup
if color:
color_clean = color.rstrip("]").lstrip("[")
end_clean = f"[/{color_clean}]"
else:
color_clean = ""
end_clean = ""
return f"{color}{glyph} {entry.display_name:<25} {entry.duration_hint}{end_clean}"
def set_status(self, test_type: TestType, status: str) -> None:
"""Update the status of a test entry."""
self._statuses[test_type] = status
self._refresh_list()
def _refresh_list(self) -> None:
"""Refresh the list display."""
try:
list_view = self.query_one("#test-list", ListView)
# Update labels
for i, entry in enumerate(self._entries):
items = list_view.children
if i < len(items):
label = items[i].query_one(Label)
label.update(self._format_entry(entry))
except Exception:
pass
def get_selected_entry(self) -> Optional[TestEntry]:
"""Get the currently selected test entry."""
try:
list_view = self.query_one("#test-list", ListView)
idx = list_view.index
if 0 <= idx < len(self._entries):
return self._entries[idx]
except Exception:
pass
return None
def on_list_view_selected(self, event: ListView.Selected) -> None:
"""Handle list selection."""
entry = self.get_selected_entry()
if entry:
self.post_message(self.TestSelected(entry))
def on_list_view_highlighted(self, event: ListView.Highlighted) -> None:
"""Handle list highlight change."""
entry = self.get_selected_entry()
if entry:
self.post_message(self.TestSelected(entry))
+34
View File
@@ -0,0 +1,34 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "club3090-test-console"
version = "0.1.0"
description = "A lazydocker-style TUI for the club-3090 AI inference stack test suite"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"textual>=0.60",
"rich>=13.0",
"httpx>=0.25",
]
[project.scripts]
c3t = "club3090_test_console.__main__:main"
[tool.hatch.build.targets.wheel]
packages = ["club3090_test_console"]
[tool.pytest.ini_options]
testpaths = ["tests"]
asyncio_mode = "auto"
filterwarnings = [
"ignore::pytest.PytestCollectionWarning",
]
[dependency-groups]
dev = [
"pytest>=9.1.0",
"pytest-asyncio>=1.4.0",
]
+120
View File
@@ -0,0 +1,120 @@
# Test Console Fixes Summary
## Issues Fixed
### 1. History View UI Distortion
**Problem**: Tabbing through the history detail screen caused UI distortion and the Close button would get cut off.
**Solution**:
- Simplified the layout from complex nested Grid/Vertical containers to a single Vertical container
- Removed overly restrictive min/max height constraints
- Used standard Textual layout patterns: Vertical with 1fr content area and 3-unit button bar
- Made the layout more flexible and responsive
**Files Changed**:
- `widgets/history_view.py`: Simplified RunDetailScreen layout
### 2. Quality Tests Config Missing MAX_TOKENS
**Problem**: Quality tests config dialog didn't expose MAX_TOKENS and THINKING_MAX_TOKENS environment variables.
**Solution**:
- Added `max_tokens` and `thinking_max_tokens` fields to TestConfig dataclass
- Added input fields to quality config dialog
- Updated `_read_config()` to read these values
- Updated runner to pass MAX_TOKENS and THINKING_MAX_TOKENS env vars
**Files Changed**:
- `runner.py`: Added fields to TestConfig, updated quality command builder
- `app.py`: Added input fields to quality config, updated _read_config()
### 3. Rebench Full Config Missing MAX_TOKENS
**Problem**: Rebench full config dialog didn't expose MAX_TOKENS and THINKING_MAX_TOKENS environment variables.
**Solution**:
- Reused the same fields added for quality tests
- Added input fields to rebench config dialog
- Updated `_read_config()` to read these values
- Updated runner to pass MAX_TOKENS and THINKING_MAX_TOKENS env vars
**Files Changed**:
- `runner.py`: Updated rebench command builder
- `app.py`: Added input fields to rebench config, updated _read_config()
### 4. Rebench Full Config Buttons Truncated
**Problem**: The rebench full config dialog had so many fields that the Run/Cancel buttons at the bottom were being cut off.
**Solution**:
- Increased max-height from 35 to 90% to allow more vertical space
- Wrapped config fields in a scrollable container (`#config-fields`)
- Used flex layout: title + hint + scrollable fields + button bar
- Button bar now has fixed height of 3 units, always visible
**Files Changed**:
- `app.py`: Updated ConfigScreen CSS and compose() method
## Test Results
### Unit Tests
```
83 tests passed ✅
```
### Headless Integration Tests
```
30/30 key interactions passed ✅
- Help screen opens/closes
- History screen opens/closes
- Manual target screen opens/closes
- All 6 test configs open/close
- Tab navigation works in config dialogs
- All global keybindings work (r, f, x, q)
```
### Manual Verification
```
✅ Quality config has MAX_TOKENS input field
✅ Quality config has THINKING_MAX_TOKENS input field
✅ Rebench config has MAX_TOKENS input field
✅ Rebench config has THINKING_MAX_TOKENS input field
✅ Rebench config Run/Cancel buttons are visible
✅ History detail screen opens without distortion
```
## Technical Details
### Config Dialog Structure
```
ConfigScreen > Vertical (max-height: 90%)
├── Label (config-title, height: 1)
├── Label (config-hint, height: 1)
├── Vertical (config-fields, height: 1fr, scrollable)
│ ├── Label + Select/Input pairs (varies by test type)
│ └── ... (up to 10+ fields for rebench)
└── Horizontal (button-bar, height: 3)
├── Button (Run)
└── Button (Cancel)
```
### History Detail Screen Structure
```
RunDetailScreen > Vertical (height: 100%)
├── Label (detail-title, height: 1)
├── RichLog (detail-content, height: 1fr, scrollable)
└── Horizontal (button-bar, height: 3)
├── Button (Summary)
├── Button (Report, conditional)
├── Button (Log, conditional)
└── Button (Close)
```
### Environment Variables Passed
- **Quality Tests**: MAX_TOKENS, THINKING_MAX_TOKENS
- **Rebench Full**: MAX_TOKENS, THINKING_MAX_TOKENS
- **Bench Tests**: RUNS, WARMUPS, ONLY, FORCE_TOKENS, ENABLE_THINKING
## Files Modified
1. `club3090_test_console/app.py` - Config dialogs and main app
2. `club3090_test_console/runner.py` - Test config and command builder
3. `club3090_test_console/widgets/history_view.py` - History detail screen
All changes are backward compatible and don't break existing functionality.
+214
View File
@@ -0,0 +1,214 @@
"""Tests for the detection module."""
from __future__ import annotations
import asyncio
import json
from unittest.mock import AsyncMock, patch, MagicMock
import pytest
from club3090_test_console.detect import (
ServingTarget,
GpuInfo,
PORT_MAP_BROAD_RE,
_classify_engine,
_classify_engine_from_container,
match_target_to_registry,
)
# ============================================================================
# Test port regex
# ============================================================================
class TestPortRegex:
def test_vllm_port(self):
m = PORT_MAP_BROAD_RE.search("0.0.0.0:8010->8000/tcp")
assert m is not None
assert m.group(1) == "8010"
assert m.group(2) == "8000"
def test_llamacpp_port(self):
m = PORT_MAP_BROAD_RE.search("0.0.0.0:8020->8080/tcp")
assert m is not None
assert m.group(1) == "8020"
assert m.group(2) == "8080"
def test_sglang_port(self):
m = PORT_MAP_BROAD_RE.search("0.0.0.0:30000->30000/tcp")
assert m is not None
assert m.group(1) == "30000"
assert m.group(2) == "30000"
def test_ipv6_loopback(self):
m = PORT_MAP_BROAD_RE.search("[::]:8011->8000/tcp")
assert m is not None
assert m.group(1) == "8011"
def test_localhost_only(self):
m = PORT_MAP_BROAD_RE.search("127.0.0.1:8011->8000/tcp")
assert m is not None
assert m.group(1) == "8011"
def test_non_engine_port_ignored(self):
m = PORT_MAP_BROAD_RE.search("0.0.0.0:8188->8188/tcp")
assert m is None # 8188 is not an engine port
def test_multiple_mappings(self):
line = "0.0.0.0:8010->8000/tcp, :::8010->8000/tcp"
matches = list(PORT_MAP_BROAD_RE.finditer(line))
assert len(matches) == 2
# ============================================================================
# Test engine classification
# ============================================================================
class TestEngineClassification:
def test_from_port(self):
assert _classify_engine("8000") == "vllm"
assert _classify_engine("8080") == "llamacpp"
assert _classify_engine("30000") == "sglang"
assert _classify_engine("9999") == "unknown"
def test_from_container_name(self):
assert _classify_engine_from_container("vllm-qwen36-27b") == "vllm"
assert _classify_engine_from_container("llama-cpp-pi-reasoning") == "llamacpp"
assert _classify_engine_from_container("ik-llama-cpp-dual") == "llamacpp"
assert _classify_engine_from_container("sglang-main") == "sglang"
assert _classify_engine_from_container("beellama-dflash") == "beellama"
assert _classify_engine_from_container("random-container") == "unknown"
# ============================================================================
# Test ServingTarget
# ============================================================================
class TestServingTarget:
def test_is_localhost(self):
t = ServingTarget(url="http://localhost:8010")
assert t.is_localhost is True
t = ServingTarget(url="http://127.0.0.1:8010")
assert t.is_localhost is True
t = ServingTarget(url="http://192.168.1.50:8010")
assert t.is_localhost is False
def test_is_active(self):
t = ServingTarget(url="http://localhost:8010", model="test-model", health="serving")
assert t.is_active is True
t = ServingTarget(url="http://localhost:8010", model="test-model", health="unreachable")
assert t.is_active is False
t = ServingTarget(url="", model="", health="unknown")
assert t.is_active is False
# ============================================================================
# Test registry matching
# ============================================================================
class TestRegistryMatching:
def test_match_by_port(self):
target = ServingTarget(host_port=8010, container="vllm-test")
variants = [
{"slug": "vllm/dual", "port": 8010, "model": "qwen", "engine": "vllm",
"kvcalc_key": "fp8", "status": "production", "container": "vllm_qwen",
"compose_dir": "", "file": "", "switch_engine": "", "launch_engine": "",
"compose_path": "", "ctx_label": "", "status_note": ""},
]
result = match_target_to_registry(target, variants)
assert result.slug == "vllm/dual"
assert result.status == "production"
def test_match_by_container_name(self):
target = ServingTarget(host_port=9999, container="vllm-qwen36-27b")
variants = [
{"slug": "vllm/dual", "port": 8010, "model": "qwen", "engine": "vllm",
"kvcalc_key": "fp8", "status": "production", "container": "vllm_qwen36_27b",
"compose_dir": "", "file": "", "switch_engine": "", "launch_engine": "",
"compose_path": "", "ctx_label": "", "status_note": ""},
]
result = match_target_to_registry(target, variants)
assert result.slug == "vllm/dual"
def test_no_match(self):
target = ServingTarget(host_port=9999, container="unknown-thing")
variants = [
{"slug": "vllm/dual", "port": 8010, "model": "qwen", "engine": "vllm",
"kvcalc_key": "fp8", "status": "production", "container": "vllm_qwen",
"compose_dir": "", "file": "", "switch_engine": "", "launch_engine": "",
"compose_path": "", "ctx_label": "", "status_note": ""},
]
result = match_target_to_registry(target, variants)
assert result.slug == ""
# ============================================================================
# Test nothing-serving path
# ============================================================================
class TestNothingServing:
@pytest.mark.asyncio
async def test_empty_docker_ps(self):
"""When docker ps returns nothing, health should be 'unreachable'."""
from club3090_test_console.detect import detect_endpoint
mock_proc = AsyncMock()
mock_proc.communicate = AsyncMock(return_value=(b"", b""))
with patch("asyncio.create_subprocess_exec", return_value=mock_proc):
target = await detect_endpoint()
assert target.health == "unreachable"
assert not target.model
assert not target.url
class TestDualStackDedup:
"""Test that dual-stack Docker output is deduped and non-engine containers are filtered."""
def test_dual_stack_dedup(self):
"""Same container with 0.0.0.0 and [::] mappings should produce one candidate."""
from club3090_test_console.detect import PORT_MAP_BROAD_RE, ENGINE_PREFIXES
# Simulate Docker dual-stack output
ports_str = "0.0.0.0:8010->8000/tcp, [::]:8010->8000/tcp"
matches = list(PORT_MAP_BROAD_RE.finditer(ports_str))
assert len(matches) == 2 # Two raw matches
# Dedup logic
seen = set()
unique = []
container_name = "vllm-qwen36-27b"
for m in matches:
key = (container_name, int(m.group(1)))
if key not in seen:
seen.add(key)
unique.append(m)
assert len(unique) == 1 # Deduped to one
def test_open_webui_filtered(self):
"""Open WebUI maps 8080->8080 but isn't an engine container."""
from club3090_test_console.detect import ENGINE_PREFIXES
assert not ENGINE_PREFIXES.match("open-webui")
assert not ENGINE_PREFIXES.match("nginx-proxy")
assert not ENGINE_PREFIXES.match("litellm-gateway")
assert ENGINE_PREFIXES.match("vllm-qwen36-27b")
assert ENGINE_PREFIXES.match("llama-cpp-pi-reasoning")
assert ENGINE_PREFIXES.match("sglang-main")
assert ENGINE_PREFIXES.match("beellama-dflash")
def test_multiple_containers_detected(self):
"""Truly different containers should set health=multiple."""
from club3090_test_console.detect import ENGINE_PREFIXES
candidates = [
("vllm-qwen36-27b", 8010, 8000, "vllm"),
("llama-cpp-pi-reasoning", 8063, 8080, "llamacpp"),
]
unique_containers = set(c[0] for c in candidates)
assert len(unique_containers) == 2 # Two different containers
+480
View File
@@ -0,0 +1,480 @@
"""Tests for the parsers module — exercises all output formats from spec Section 10."""
from __future__ import annotations
import pytest
from club3090_test_console.parsers import (
BenchParser,
VerifyParser,
StressParser,
QualityParser,
SoakParser,
RebenchParser,
Status,
strip_ansi,
)
# ============================================================================
# Test strip_ansi
# ============================================================================
def test_strip_ansi():
assert strip_ansi("\033[32m✓\033[0m test") == "✓ test"
assert strip_ansi("no codes here") == "no codes here"
assert strip_ansi("\033[1;31m✗\033[0m fail") == "✗ fail"
# ============================================================================
# Test BenchParser
# ============================================================================
class TestBenchParser:
def test_section_header(self):
p = BenchParser()
event = p.parse_line("========== NARRATIVE (prompt=65 chars, max_tokens=1000) ==========")
assert event is not None
assert event.event_type == "bench_section"
assert event.data["section"] == "narrative"
def test_warmup_line(self):
p = BenchParser()
p.parse_line("========== NARRATIVE (prompt=65 chars, max_tokens=1000) ==========")
event = p.parse_line(" warm-1 wall= 12.05s ttft= 120ms toks=1000 wall_TPS= 83.03 decode_TPS= 83.86")
assert event is not None
assert event.event_type == "bench_run"
assert event.data["type"] == "warm"
assert event.data["run"] == 1
assert event.data["wall_s"] == 12.05
assert event.data["ttft_ms"] == 120
assert event.data["tokens"] == 1000
assert event.data["wall_tps"] == 83.03
assert event.data["decode_tps"] == 83.86
def test_measured_run_line(self):
p = BenchParser()
p.parse_line("========== NARRATIVE (prompt=65 chars, max_tokens=1000) ==========")
event = p.parse_line(" run-1 wall= 11.53s ttft= 118ms toks=1000 wall_TPS= 86.68 decode_TPS= 87.58")
assert event is not None
assert event.event_type == "bench_run"
assert event.data["type"] == "run"
assert event.data["wall_tps"] == 86.68
assert event.data["decode_tps"] == 87.58
assert len(p.runs["narrative"]) == 1
def test_summary_metric(self):
p = BenchParser()
p.current_section = "narrative"
event = p.parse_line(" wall_TPS mean= 84.14 std= 2.41 CV= 2.9% min=81.89 max=86.68")
assert event is not None
assert event.event_type == "summary_metric"
assert event.data["metric"] == "wall_TPS"
assert event.data["mean"] == 84.14
assert event.data["cv"] == 2.9
def test_decode_tps_summary(self):
p = BenchParser()
p.current_section = "narrative"
event = p.parse_line(" decode_TPS mean= 84.99 std= 2.45 CV= 2.9% min=82.70 max=87.58")
assert event is not None
assert event.data["metric"] == "decode_TPS"
assert event.data["mean"] == 84.99
def test_ttft_summary(self):
p = BenchParser()
p.current_section = "narrative"
event = p.parse_line(" TTFT mean= 119ms std= 1ms min=118ms max=120ms")
assert event is not None
assert event.event_type == "summary_ttft"
assert event.data["ttft_mean_ms"] == 119
def test_multiple_runs_accumulate(self):
p = BenchParser()
p.parse_line("========== NARRATIVE (prompt=65 chars, max_tokens=1000) ==========")
p.parse_line(" run-1 wall= 11.53s ttft= 118ms toks=1000 wall_TPS= 86.68 decode_TPS= 87.58")
p.parse_line(" run-2 wall= 11.93s ttft= 119ms toks=1000 wall_TPS= 83.81 decode_TPS= 84.65")
assert len(p.runs["narrative"]) == 2
def test_unmatched_line_returns_none(self):
p = BenchParser()
assert p.parse_line("some random log line") is None
# ============================================================================
# Test VerifyParser
# ============================================================================
class TestVerifyParser:
def test_step_header(self):
p = VerifyParser()
event = p.parse_line("[3/9] Basic completion — capital of France ...")
assert event is not None
assert event.event_type == "verify_step"
assert event.data["step"] == 3
assert event.data["total"] == 9
assert "capital of France" in event.data["name"]
def test_pass_check(self):
p = VerifyParser()
event = p.parse_line(" ✓ reply contains 'Paris'")
assert event is not None
assert event.event_type == "verify_check"
assert event.data["status"] == "passed"
assert event.data["glyph"] == ""
def test_fail_check(self):
p = VerifyParser()
event = p.parse_line(" ✗ tool-call request failed")
assert event is not None
assert event.data["status"] == "failed"
assert event.data["glyph"] == ""
def test_skip_check(self):
p = VerifyParser()
event = p.parse_line(" ⊘ Genesis patches applied (skipped)")
assert event is not None
assert event.data["status"] == "skipped"
assert event.data["glyph"] == ""
def test_hint(self):
p = VerifyParser()
p.current_step = {"num": 1, "checks": [{"message": "test"}]}
event = p.parse_line(" → Check docker logs vllm-qwen36-27b")
assert event is not None
assert event.event_type == "verify_hint"
assert "docker logs" in event.data["hint"]
def test_all_passed_verdict(self):
p = VerifyParser()
event = p.parse_line("All checks passed. Stack is ready for full-functionality use.")
assert event is not None
assert event.event_type == "verdict"
assert event.data["status"] == Status.PASSED
def test_checks_failed_verdict(self):
p = VerifyParser()
event = p.parse_line("3 check(s) failed. See hints above.")
assert event is not None
assert event.event_type == "verdict"
assert event.data["status"] == Status.FAILED
assert event.data["failed"] == 3
def test_ansi_stripped(self):
p = VerifyParser()
event = p.parse_line(" \033[32m✓\033[0m reply contains 'Paris'")
assert event is not None
assert event.data["status"] == "passed"
def test_counters(self):
p = VerifyParser()
p.parse_line(" ✓ test 1")
p.parse_line(" ✗ test 2")
p.parse_line(" ⊘ test 3")
assert p.passed == 1
assert p.failed == 1
assert p.skipped == 1
# ============================================================================
# Test StressParser
# ============================================================================
class TestStressParser:
def test_probe_header(self):
p = StressParser()
event = p.parse_line("[1/8] Long-context needle small rungs (10K / 30K) ...")
assert event is not None
assert event.event_type == "stress_probe"
assert event.data["probe"] == 1
def test_token_pass(self):
p = StressParser()
event = p.parse_line(" ✓ 10000 tokens: recalled '' (got: …)")
assert event is not None
assert event.event_type == "niah_token"
assert event.data["tokens"] == 10000
assert event.data["status"] == "passed"
def test_token_partial(self):
p = StressParser()
event = p.parse_line(" △ 30000 tokens: recall MISS (…) — system OK, quality ceiling reached")
assert event is not None
assert event.data["status"] == "partial"
assert event.data["glyph"] == ""
def test_rung_pass(self):
p = StressParser()
event = p.parse_line(" ✓ rung 1/6: target=95K actual=95K tok (36%) recalled '' prefill=… t/s (…s) VRAM_free=…MB")
assert event is not None
assert event.event_type == "niah_rung"
assert event.data["rung"] == 1
assert event.data["total"] == 6
assert event.data["target_k"] == 95
assert event.data["status"] == "passed"
def test_rung_partial(self):
p = StressParser()
event = p.parse_line(" △ rung 2/6: target=125K actual=125K tok (47%) recall MISS (…) — quality ceiling reached")
assert event is not None
assert event.data["status"] == "partial"
def test_rung_failed(self):
p = StressParser()
event = p.parse_line(" ✗ rung 3/6: target=155K HTTP 500 (OOM at ~59% of n_ctx=262000)")
assert event is not None
assert event.data["status"] == "failed"
def test_rung_skipped(self):
p = StressParser()
event = p.parse_line(" ⊘ rung 4/6: target=185K HTTP 400 (exceeds engine limit — clean rejection)")
assert event is not None
assert event.data["status"] == "skipped"
def test_ladder_info(self):
p = StressParser()
event = p.parse_line(" n_ctx=262000 ladder: 95000 → 125000 → 155000 → 185000 → 215000 → 241000 (6 rungs)")
assert event is not None
assert event.event_type == "niah_ladder"
assert event.data["n_ctx"] == 262000
def test_vram_free(self):
p = StressParser()
event = p.parse_line(" VRAM free (ladder start): 12345 MB")
assert event is not None
assert event.data["vram_free_mb"] == 12345
def test_all_passed(self):
p = StressParser()
event = p.parse_line("All stress / boundary checks passed. KV-cache and prefill paths are sound for the deployed config.")
assert event is not None
assert event.data["status"] == Status.PASSED
def test_some_failed(self):
p = StressParser()
event = p.parse_line("3 stress check(s) failed. See hints above.")
assert event is not None
assert event.data["status"] == Status.FAILED
assert event.data["failed"] == 3
# ============================================================================
# Test QualityParser
# ============================================================================
class TestQualityParser:
def test_scenario_pass(self):
p = QualityParser()
event = p.parse_line(" [1/15] TC-01 ✓ passed (2.3s)")
assert event is not None
assert event.event_type == "quality_scenario"
assert event.data["num"] == 1
assert event.data["total"] == 15
assert event.data["scenario_id"] == "TC-01"
assert event.data["passed"] is True
assert event.data["elapsed_s"] == 2.3
assert event.data["pack_id"] == "toolcall-15"
def test_scenario_fail(self):
p = QualityParser()
event = p.parse_line(" [7/15] TC-07 ✗ verifier_fail (3.1s)")
assert event is not None
assert event.data["passed"] is False
assert event.data["failure_mode"] == "verifier_fail"
assert event.data["elapsed_s"] == 3.1
def test_pack_id_mapping(self):
p = QualityParser()
# IF = instructfollow
event = p.parse_line(" [1/15] IF-01 ✓ passed (1.6s)")
assert event.data["pack_id"] == "instructfollow-15"
# SO = structoutput
event = p.parse_line(" [1/15] SO-01 ✓ passed (1.2s)")
assert event.data["pack_id"] == "structoutput-15"
# RM = reasonmath
event = p.parse_line(" [1/15] RM-01 ✓ passed (2.0s)")
assert event.data["pack_id"] == "reasonmath-15"
def test_totals_accumulate(self):
p = QualityParser()
p.parse_line(" [1/15] TC-01 ✓ passed (2.3s)")
p.parse_line(" [2/15] TC-02 ✓ passed (1.5s)")
p.parse_line(" [3/15] TC-03 ✗ verifier_fail (3.1s)")
assert p.total_passed == 2
assert p.total_count == 3
def test_pack_tracking(self):
p = QualityParser()
p.parse_line(" [1/15] TC-01 ✓ passed (2.3s)")
p.parse_line(" [2/15] TC-02 ✓ passed (1.5s)")
assert p.packs["toolcall-15"]["passed"] == 2
assert p.packs["toolcall-15"]["total"] == 2
def test_total_line(self):
p = QualityParser()
event = p.parse_line("TOTAL 120/150")
assert event is not None
assert event.event_type == "quality_total"
assert event.data["passed"] == 120
assert event.data["total"] == 150
# ============================================================================
# Test SoakParser
# ============================================================================
class TestSoakParser:
def test_config_line(self):
p = SoakParser()
event = p.parse_line("[soak] mode=fresh sessions=20 turns=5 max_growth=200MiB timeout=1800s")
assert event is not None
assert event.event_type == "soak_config"
assert event.data["mode"] == "fresh"
assert event.data["sessions"] == 20
assert event.data["turns"] == 5
def test_session(self):
p = SoakParser()
event = p.parse_line("[soak] session 1/20")
assert event is not None
assert event.event_type == "soak_session"
assert event.data["session"] == 1
assert event.data["total"] == 20
def test_turn(self):
p = SoakParser()
p.current_session = 1
event = p.parse_line("[soak] turn 1/5: status=200 wall=5159ms ttft=481ms decode_tps=42.113 vram=43104MiB")
assert event is not None
assert event.event_type == "soak_turn"
assert event.data["turn"] == 1
assert event.data["status"] == 200
assert event.data["wall_ms"] == 5159
assert event.data["ttft_ms"] == 481
assert event.data["decode_tps"] == 42.113
assert event.data["vram_mib"] == 43104
def test_baseline(self):
p = SoakParser()
event = p.parse_line("[soak] warm baseline after session 1: 43104 MiB")
assert event is not None
assert event.event_type == "soak_baseline"
assert event.data["vram_mib"] == 43104
def test_verdict_pass(self):
p = SoakParser()
event = p.parse_line("[soak] verdict PASS")
assert event is not None
assert event.event_type == "verdict"
assert event.data["status"] == Status.PASSED
assert event.data["verdict"] == "PASS"
def test_verdict_fail(self):
p = SoakParser()
event = p.parse_line("[soak] verdict FAIL")
assert event.data["status"] == Status.FAILED
def test_metrics(self):
p = SoakParser()
event = p.parse_line("[soak] silent_empty 0 / 100 (0.0%)")
assert event is not None
assert event.data["key"] == "silent_empty"
assert "0 / 100" in event.data["value"]
event = p.parse_line("[soak] tps_retention 100.0%")
assert event.data["key"] == "tps_retention"
event = p.parse_line("[soak] p50_decode_tps 42.23")
assert event.data["key"] == "p50_decode_tps"
# ============================================================================
# Test RebenchParser
# ============================================================================
class TestRebenchParser:
def test_step_running(self):
p = RebenchParser()
event = p.parse_line("[verify-full] running…")
assert event is not None
assert event.event_type == "rebench_step_start"
assert event.data["step"] == "verify-full"
assert p.current_step == "verify-full"
def test_step_passed(self):
p = RebenchParser()
event = p.parse_line("[verify-full] ✓ 96s — log: results/rebench/test-tag/verify-full.log")
assert event is not None
assert event.event_type == "rebench_step_done"
assert event.data["step"] == "verify-full"
assert event.data["status"] == Status.PASSED
assert event.data["elapsed_s"] == 96
def test_step_failed(self):
p = RebenchParser()
event = p.parse_line("[bench] ✗ 14s — failed (rc=1) — log: …")
assert event is not None
assert event.data["status"] == Status.FAILED
assert event.data["rc"] == 1
def test_step_skipped(self):
p = RebenchParser()
event = p.parse_line("[quality-full] skipped — 8-pack is opt-in (pass --with-8pack-thinking=off|both)")
assert event is not None
assert event.data["status"] == Status.SKIPPED
def test_report_path(self):
p = RebenchParser()
event = p.parse_line(" report: results/rebench/test-tag/REPORT.md")
assert event is not None
assert event.data["path"] == "results/rebench/test-tag/REPORT.md"
def test_artifacts_dir(self):
p = RebenchParser()
event = p.parse_line(" artifacts: results/rebench/test-tag")
assert event is not None
assert event.data["dir"] == "results/rebench/test-tag"
def test_complete(self):
p = RebenchParser()
event = p.parse_line(" rebench complete")
assert event is not None
assert event.event_type == "rebench_complete"
assert p.complete is True
def test_full_sequence(self):
"""Test a complete rebench sequence."""
p = RebenchParser()
p.parse_line("[verify-full] running…")
assert p.steps["verify-full"]["status"] == Status.RUNNING
p.parse_line("[verify-full] ✓ 96s — log: results/rebench/tag/verify-full.log")
assert p.steps["verify-full"]["status"] == Status.PASSED
p.parse_line("[bench] running…")
p.parse_line("[bench] ✓ 312s — log: results/rebench/tag/bench.log")
assert p.steps["bench"]["status"] == Status.PASSED
p.parse_line("[quality-full] skipped — 8-pack is opt-in")
assert p.steps["quality-full"]["status"] == Status.SKIPPED
p.parse_line(" rebench complete")
assert p.complete is True
# ============================================================================
# Test parser factory
# ============================================================================
class TestParserFactory:
def test_get_parser(self):
from club3090_test_console.parsers import get_parser, TestType
assert isinstance(get_parser(TestType.BENCH), BenchParser)
assert isinstance(get_parser(TestType.VERIFY), VerifyParser)
assert isinstance(get_parser(TestType.VERIFY_FULL), VerifyParser)
assert isinstance(get_parser(TestType.VERIFY_STRESS), StressParser)
assert isinstance(get_parser(TestType.QUALITY), QualityParser)
assert isinstance(get_parser(TestType.SOAK), SoakParser)
assert isinstance(get_parser(TestType.REBENCH), RebenchParser)
+215
View File
@@ -0,0 +1,215 @@
"""Tests for the runner module — subprocess management and env injection."""
from __future__ import annotations
import asyncio
import os
from pathlib import Path
from unittest.mock import AsyncMock, patch, MagicMock
import pytest
from club3090_test_console.runner import TestRunner, TestConfig, RunState
from club3090_test_console.detect import ServingTarget, GpuInfo
from club3090_test_console.parsers import TestType
# ============================================================================
# Test command building
# ============================================================================
class TestCommandBuilding:
"""Test that _build_command produces correct commands and env."""
def _make_runner_with_target(self, target: ServingTarget) -> TestRunner:
runner = TestRunner(repo_root=Path("/repo"))
state = RunState(
test_type=TestType.BENCH,
config=TestConfig(test_type=TestType.BENCH),
target=target,
)
runner.current_run = state
return runner
def test_bench_command(self):
target = ServingTarget(url="http://localhost:8010", model="qwen3.6-27b", container="vllm-test")
runner = self._make_runner_with_target(target)
config = TestConfig(test_type=TestType.BENCH, run_count=3, warmups=2, only="narr")
runner.current_run.config = config
cmd, env = runner._build_command(config)
assert "scripts/bench.sh" in cmd
assert env["URL"] == "http://localhost:8010"
assert env["MODEL"] == "qwen3.6-27b"
assert env["CONTAINER"] == "vllm-test"
assert env["RUNS"] == "3"
assert env["WARMUPS"] == "2"
assert env["ONLY"] == "narr"
assert env["PYTHONUNBUFFERED"] == "1"
def test_bench_with_thinking(self):
target = ServingTarget(url="http://localhost:8010", model="qwen", container="vllm-test")
runner = self._make_runner_with_target(target)
config = TestConfig(test_type=TestType.BENCH, enable_thinking=True)
cmd, env = runner._build_command(config)
assert env["ENABLE_THINKING"] == "1"
def test_bench_with_force_tokens(self):
target = ServingTarget(url="http://localhost:8010", model="qwen", container="vllm-test")
runner = self._make_runner_with_target(target)
config = TestConfig(test_type=TestType.BENCH, force_tokens=2000)
cmd, env = runner._build_command(config)
assert env["FORCE_TOKENS"] == "2000"
def test_verify_full_command(self):
target = ServingTarget(url="http://localhost:8010", model="qwen", container="vllm-test")
runner = self._make_runner_with_target(target)
config = TestConfig(test_type=TestType.VERIFY_FULL, skip_tools=True, run_bench=True)
cmd, env = runner._build_command(config)
assert "scripts/verify-full.sh" in cmd
assert "--bench" in cmd
assert env["SKIP_TOOLS"] == "1"
def test_verify_stress_command(self):
target = ServingTarget(url="http://localhost:8010", model="qwen", container="vllm-test")
runner = self._make_runner_with_target(target)
config = TestConfig(
test_type=TestType.VERIFY_STRESS,
skip_longctx=True,
skip_tool_prefill=True,
)
cmd, env = runner._build_command(config)
assert "scripts/verify-stress.sh" in cmd
assert env["SKIP_LONGCTX"] == "1"
assert env["SKIP_TOOL_PREFILL"] == "1"
def test_quality_command(self):
target = ServingTarget(url="http://localhost:8010", model="qwen", container="vllm-test")
runner = self._make_runner_with_target(target)
config = TestConfig(
test_type=TestType.QUALITY,
quality_tier="full",
quality_pack="toolcall-15",
quality_repeat=3,
)
cmd, env = runner._build_command(config)
assert "scripts/quality-test.sh" in cmd
assert "--full" in cmd
assert "--pack" in cmd
assert "toolcall-15" in cmd
assert "--repeat" in cmd
assert "3" in cmd
assert env["BENCHLOCAL_HERMES_RESOLVE_LOCALHOST"] == "1" # localhost
def test_quality_non_localhost_no_hermes(self):
target = ServingTarget(url="http://192.168.1.50:8887", model="qwen", container="vllm-test")
runner = self._make_runner_with_target(target)
config = TestConfig(test_type=TestType.QUALITY, quality_tier="medium")
cmd, env = runner._build_command(config)
assert "BENCHLOCAL_HERMES_RESOLVE_LOCALHOST" not in env
def test_soak_command(self):
target = ServingTarget(url="http://localhost:8010", model="qwen", container="vllm-test")
runner = self._make_runner_with_target(target)
config = TestConfig(
test_type=TestType.SOAK,
soak_mode="fresh",
soak_sessions=20,
soak_turns=10,
soak_max_growth=300,
)
cmd, env = runner._build_command(config)
assert "scripts/soak-test.sh" in cmd
assert "--fresh" in cmd
assert env["SOAK_SESSIONS"] == "20"
assert env["SOAK_TURNS"] == "10"
assert env["SOAK_MAX_GROWTH_MIB"] == "300"
def test_rebench_command(self):
target = ServingTarget(url="http://localhost:8010", model="qwen", container="vllm-test")
runner = self._make_runner_with_target(target)
config = TestConfig(
test_type=TestType.REBENCH,
rebench_8pack="both",
rebench_skip=["soak"],
rebench_tag="test-run",
)
cmd, env = runner._build_command(config)
assert "scripts/rebench-full.sh" in cmd
assert "--with-8pack-thinking=both" in cmd
assert "--skip=soak" in cmd
assert "--tag=test-run" in cmd
def test_rebench_external_endpoint(self):
target = ServingTarget(url="http://localhost:8010", model="qwen", container="vllm-test")
runner = self._make_runner_with_target(target)
config = TestConfig(
test_type=TestType.REBENCH,
external_url="http://192.168.1.50:8887",
external_model="Qwen3.6-27B",
external_engine="llama-cpp",
)
cmd, env = runner._build_command(config)
assert "--url" in cmd
assert "http://192.168.1.50:8887" in cmd
assert "--model" in cmd
assert "Qwen3.6-27B" in cmd
assert "--engine" in cmd
assert "llama-cpp" in cmd
assert env["PREFLIGHT_NO_AUTODETECT"] == "1"
assert env["CONTAINER"] == "none"
def test_stdbuf_wrapping(self):
"""All commands should be wrapped in stdbuf for line-buffered output."""
target = ServingTarget(url="http://localhost:8010", model="qwen", container="vllm-test")
runner = self._make_runner_with_target(target)
for tt in TestType:
config = TestConfig(test_type=tt)
cmd, _ = runner._build_command(config)
assert cmd[0] == "stdbuf", f"{tt} command not wrapped in stdbuf"
# ============================================================================
# Test RunState
# ============================================================================
class TestRunState:
def test_elapsed_while_running(self):
import time
state = RunState(
test_type=TestType.BENCH,
config=TestConfig(test_type=TestType.BENCH),
target=ServingTarget(),
started=time.time() - 10,
)
assert state.elapsed_s >= 9
assert state.is_running is True
assert state.is_finished is False
def test_elapsed_after_finish(self):
state = RunState(
test_type=TestType.BENCH,
config=TestConfig(test_type=TestType.BENCH),
target=ServingTarget(),
started=1000.0,
finished=1060.0,
)
assert state.elapsed_s == 60.0
assert state.is_running is False
assert state.is_finished is True
def test_not_started(self):
state = RunState(
test_type=TestType.BENCH,
config=TestConfig(test_type=TestType.BENCH),
target=ServingTarget(),
)
assert state.elapsed_s == 0
assert state.is_running is False
+281
View File
@@ -0,0 +1,281 @@
version = 1
revision = 3
requires-python = ">=3.11"
[[package]]
name = "anyio"
version = "4.14.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1c/b5/001890774a9552aff22502b8da382593109ce0c95314abaebbb116567545/anyio-4.14.0.tar.gz", hash = "sha256:b47c1f9ccf73e67021df785332508f99379c68fa7d0684e8e3492cb1d4b23f89", size = 253586, upload-time = "2026-06-15T22:00:49.021Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ba/16/9826f089383c593cdfc4a6e5aca94d9e91ae1692c57af82c3b2aa5e810f7/anyio-4.14.0-py3-none-any.whl", hash = "sha256:dd9b7a2a9799ed6552fde617b2c5df02b7fdd7d88392fc48101e51bae46164d9", size = 123506, upload-time = "2026-06-15T22:00:47.595Z" },
]
[[package]]
name = "certifi"
version = "2026.6.17"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" },
]
[[package]]
name = "club3090-test-console"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "httpx" },
{ name = "rich" },
{ name = "textual" },
]
[package.dev-dependencies]
dev = [
{ name = "pytest" },
{ name = "pytest-asyncio" },
]
[package.metadata]
requires-dist = [
{ name = "httpx", specifier = ">=0.25" },
{ name = "rich", specifier = ">=13.0" },
{ name = "textual", specifier = ">=0.60" },
]
[package.metadata.requires-dev]
dev = [
{ name = "pytest", specifier = ">=9.1.0" },
{ name = "pytest-asyncio", specifier = ">=1.4.0" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "h11"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "httpcore"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[[package]]
name = "idna"
version = "3.18"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "linkify-it-py"
version = "2.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "uc-micro-py" },
]
sdist = { url = "https://files.pythonhosted.org/packages/2e/c9/06ea13676ef354f0af6169587ae292d3e2406e212876a413bf9eece4eb23/linkify_it_py-2.1.0.tar.gz", hash = "sha256:43360231720999c10e9328dc3691160e27a718e280673d444c38d7d3aaa3b98b", size = 29158, upload-time = "2026-03-01T07:48:47.683Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b4/de/88b3be5c31b22333b3ca2f6ff1de4e863d8fe45aaea7485f591970ec1d3e/linkify_it_py-2.1.0-py3-none-any.whl", hash = "sha256:0d252c1594ecba2ecedc444053db5d3a9b7ec1b0dd929c8f1d74dce89f86c05e", size = 19878, upload-time = "2026-03-01T07:48:46.098Z" },
]
[[package]]
name = "markdown-it-py"
version = "4.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mdurl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" },
]
[package.optional-dependencies]
linkify = [
{ name = "linkify-it-py" },
]
[[package]]
name = "mdit-py-plugins"
version = "0.6.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py" },
]
sdist = { url = "https://files.pythonhosted.org/packages/59/fc/f8d0863f8862f25602c0404d75568e89fb6b4109804645e5cdfb1be5cf56/mdit_py_plugins-0.6.1.tar.gz", hash = "sha256:a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0", size = 56114, upload-time = "2026-05-13T09:03:38.91Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl", hash = "sha256:214c82fb2ac524472ab6a5bcab1de80f73b50443e187f401bfd77efbc7c6481d", size = 66663, upload-time = "2026-05-13T09:03:37.76Z" },
]
[[package]]
name = "mdurl"
version = "0.1.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
]
[[package]]
name = "packaging"
version = "26.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
]
[[package]]
name = "platformdirs"
version = "4.10.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7", size = 31224, upload-time = "2026-05-28T03:32:53.587Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pygments"
version = "2.20.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
]
[[package]]
name = "pytest"
version = "9.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/84/0e/b5858858d74958632c49b72cb25a3976ff9f632397626715be71c89d3971/pytest-9.1.0.tar.gz", hash = "sha256:41dd9148c08072446394cefd3d79701701335a9f4cae69ba92e39f6c7f5c061c", size = 1634181, upload-time = "2026-06-13T18:52:45.983Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8b/5a/ba30a81239b909821b3153e303e7def45178bf353da4f72380e6c5e8793b/pytest-9.1.0-py3-none-any.whl", hash = "sha256:8ebb0e7888bdf2bdfc602ec51f8f62d50200af37356c74e503c79a94f5c81f32", size = 386453, upload-time = "2026-06-13T18:52:44.045Z" },
]
[[package]]
name = "pytest-asyncio"
version = "1.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pytest" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" },
]
[[package]]
name = "rich"
version = "15.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" },
]
[[package]]
name = "textual"
version = "8.2.7"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py", extra = ["linkify"] },
{ name = "mdit-py-plugins" },
{ name = "platformdirs" },
{ name = "pygments" },
{ name = "rich" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9b/7a/c519db0aba5024f86e71e9631810bfdd6866ed2c8695bd7fa34b90e7ef59/textual-8.2.7.tar.gz", hash = "sha256:658f568ff81e30ed43890c3e07520390e5cf1b4763822006e060656b0a88f105", size = 1859249, upload-time = "2026-05-19T10:52:49.531Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a8/f5/c1e18bc0707300a0e90204343abbf7d7acd6fb7ebe03a6d4893b99a234b8/textual-8.2.7-py3-none-any.whl", hash = "sha256:4caaa13a90bc4cf9c6c862c067ccd34fe84e9c161710a2a907a8026313b6bd73", size = 731129, upload-time = "2026-05-19T10:52:51.773Z" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]
[[package]]
name = "uc-micro-py"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/78/67/9a363818028526e2d4579334460df777115bdec1bb77c08f9db88f6389f2/uc_micro_py-2.0.0.tar.gz", hash = "sha256:c53691e495c8db60e16ffc4861a35469b0ba0821fe409a8a7a0a71864d33a811", size = 6611, upload-time = "2026-03-01T06:31:27.526Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/61/73/d21edf5b204d1467e06500080a50f79d49ef2b997c79123a536d4a17d97c/uc_micro_py-2.0.0-py3-none-any.whl", hash = "sha256:3603a3859af53e5a39bc7677713c78ea6589ff188d70f4fee165db88e22b242c", size = 6383, upload-time = "2026-03-01T06:31:26.257Z" },
]