Source code for simvx.ide.playtest

"""IDE playtest harness -- headless IDE testing with screenshots and input simulation.

Launches the IDE off-screen using App.run_headless(), captures screenshots to PNG,
and provides the widget tree as text so an LLM can read widget positions and compose
Click/TypeText/PressKey steps to interact with the IDE.

Usage (from Claude Code)::

    # 1. Capture a screenshot of the IDE at rest
    uv run python -c "
    from simvx.ide.playtest import ide_screenshot
    path, tree = ide_screenshot(frames=90)
    print(tree)
    "
    # Then: Read /tmp/ide_test/screenshot.png to see it

    # 2. Run a scripted interaction
    uv run python -c "
    from simvx.ide.playtest import ide_run
    from simvx.core.scripted_demo import Click, TypeText, PressKey, Wait
    from simvx.core.input.enums import Key
    report = ide_run([
        Wait(0.5),
        Click(700, 450),        # click in editor area
        TypeText('hello world'),
    ])
    print(report)
    "
"""


from __future__ import annotations

import logging
import time
from pathlib import Path

from simvx.core.scripted_demo import DemoRunner
from simvx.core.testing import NodeCounter, scene_describe, ui_describe

from .app import IDERoot

log = logging.getLogger(__name__)

_OUTPUT_DIR = Path("/tmp/ide_test")


[docs] def ide_screenshot( *, project_dir: str | None = None, frames: int = 90, width: int = 1400, height: int = 900, output_dir: str | Path | None = None, ) -> tuple[str, str]: """Launch the IDE headlessly, render *frames* frames, save a screenshot. Args: project_dir: Optional project directory to open. frames: Number of frames to render (60 = ~1 second at 60fps). width: Window width. height: Window height. output_dir: Where to save the screenshot. Default ``/tmp/ide_test``. Returns: (screenshot_path, ui_tree_text) -- path to PNG and widget tree description. """ from simvx.graphics import App, save_png out = Path(output_dir) if output_dir else _OUTPUT_DIR out.mkdir(parents=True, exist_ok=True) ide = IDERoot() if project_dir: _orig_ready = ide.ready def _ready_with_project(): _orig_ready() root = str(Path(project_dir).resolve()) ide.state.project_root = root if ide._file_browser: ide._file_browser.set_root(root) ide.ready = _ready_with_project app = App(width=width, height=height, title="SimVX IDE [test]", visible=False) captured = app.run_headless(ide, frames=frames, capture_frames=[frames - 1]) screenshot_path = out / "screenshot.png" if captured: save_png(screenshot_path, captured[0]) # Build the UI tree text with rects try: tree_text = ui_describe(ide) except Exception: tree_text = "(ui_describe failed)" return str(screenshot_path), tree_text
[docs] def ide_run( steps: list, *, project_dir: str | None = None, extra_frames: int = 30, init_frames: int = 90, width: int = 1400, height: int = 900, speed: float = 50.0, output_dir: str | Path | None = None, capture_every: int = 0, ) -> str: """Run a scripted interaction against the IDE and return a text report. Args: steps: List of DemoRunner step dataclasses (Click, TypeText, PressKey, Wait, etc). project_dir: Optional project directory to open. extra_frames: Frames to render after steps complete (for settling). init_frames: Frames to render before starting steps (IDE initialisation). width: Window width. height: Window height. speed: Demo playback speed multiplier. output_dir: Where to save screenshots. Default ``/tmp/ide_test``. capture_every: Capture a screenshot every N frames (0 = only at end + step boundaries). Returns: Text report with screenshot paths and UI tree. """ from simvx.graphics import App, save_png out = Path(output_dir) if output_dir else _OUTPUT_DIR out.mkdir(parents=True, exist_ok=True) screenshots_dir = out / "screenshots" screenshots_dir.mkdir(parents=True, exist_ok=True) ide = IDERoot() demo = DemoRunner(steps, test_mode=True, speed=speed, delay_between_steps=0.02) if project_dir: _orig_ready = ide.ready def _ready_with_project(): _orig_ready() root = str(Path(project_dir).resolve()) ide.state.project_root = root if ide._file_browser: ide._file_browser.set_root(root) ide.ready = _ready_with_project # Track state across frames demo_attached = [False] done_frame = [0] frame_captures: list[tuple[int, str]] = [] # (frame_idx, label) max_frames = init_frames + len(steps) * 120 + extra_frames # generous budget def on_frame(frame_idx: int, _t: float): # Attach DemoRunner after init_frames if not demo_attached[0] and frame_idx >= init_frames: ide.add_child(demo) demo_attached[0] = True # After demo completes, let extra settling frames render then stop if demo.is_done: if done_frame[0] == 0: done_frame[0] = frame_idx if frame_idx >= done_frame[0] + extra_frames: return False def should_capture(frame_idx: int) -> bool: # Capture init frame if frame_idx == init_frames: frame_captures.append((frame_idx, "init")) return True # Capture periodic frames if capture_every > 0 and frame_idx % capture_every == 0: frame_captures.append((frame_idx, f"frame_{frame_idx}")) return True # Capture the final frame (last settling frame after demo completes) if done_frame[0] > 0 and frame_idx == done_frame[0] + extra_frames - 1: frame_captures.append((frame_idx, "final")) return True return False start = time.monotonic() app = App(width=width, height=height, title="SimVX IDE [test]", visible=False) captured = app.run_headless( ide, frames=max_frames, on_frame=on_frame, capture_fn=should_capture, ) elapsed = time.monotonic() - start # Save screenshots saved_paths: list[str] = [] for i, pixels in enumerate(captured): label = frame_captures[i][1] if i < len(frame_captures) else f"capture_{i}" path = screenshots_dir / f"{label}.png" save_png(path, pixels) saved_paths.append(str(path)) # Build report try: tree_text = ui_describe(ide) except Exception: tree_text = "(ui_describe failed)" try: node_count = NodeCounter.count(ide) except Exception: node_count = -1 failures = demo.failures status = "PASSED" if not failures else "FAILED" report_lines = [ f"IDE Playtest {status}", f" Steps: {len(steps)}, Frames: {max_frames}, Time: {elapsed:.1f}s", f" Nodes: {node_count}", f" Screenshots: {len(saved_paths)}", ] for p in saved_paths: report_lines.append(f" {p}") if failures: report_lines.append(f" Failures:") for f in failures: report_lines.append(f" - {f}") report_lines.append("") report_lines.append("=== UI Tree ===") report_lines.append(tree_text) report_text = "\n".join(report_lines) # Save report (out / "report.txt").write_text(report_text, encoding="utf-8") return report_text
[docs] def ide_find_widget(ui_tree: str, name: str) -> tuple[float, float, float, float] | None: """Parse a ui_describe() tree and return the rect of the first widget matching *name*. Returns (x, y, w, h) or None if not found. Useful for computing click targets. """ import re for line in ui_tree.split("\n"): if name in line: m = re.search(r"rect=\(([^)]+)\)", line) if m: parts = m.group(1).split(",") if len(parts) == 4: return tuple(float(p.strip()) for p in parts) # type: ignore[return-value] return None
[docs] def ide_widget_centre(ui_tree: str, name: str) -> tuple[float, float] | None: """Return the centre point of a widget found by name in a ui_describe() tree. Useful for building Click steps:: tree = ... # from ide_screenshot() cx, cy = ide_widget_centre(tree, "Settings") steps = [Click(cx, cy)] """ rect = ide_find_widget(ui_tree, name) if rect is None: return None x, y, w, h = rect return (x + w / 2, y + h / 2)