"""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