//! Scripted TUI scenario runner for kigi-tui. //! //! This layer lets UI regression tests describe a scenario declaratively, run //! the real pager binary in a PTY, interact with it through keyboard input and //! resizes, assert observable terminal output, and persist visual artifacts for //! bug triage. use std::fs; use std::path::{Component, Path, PathBuf}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use anyhow::{Context, Result, anyhow, bail}; use serde::{Deserialize, Serialize}; use crate::{ContentController, PtyHarness, StyledLine, pager_binary, parse_keys}; const SGR_LEFT_BUTTON: u16 = 0; const SGR_MIDDLE_BUTTON: u16 = 1; const SGR_RIGHT_BUTTON: u16 = 2; const SGR_DRAG_BUTTON: u16 = 32; /// SGR wheel button codes (bit 6 / +64 marks a wheel event). Public so PTY /// tests share this single definition instead of respelling 64/65. pub const SGR_SCROLL_UP: u16 = 64; pub const SGR_SCROLL_DOWN: u16 = 65; const DEFAULT_ROWS: u16 = 50; const DEFAULT_COLS: u16 = 120; const DEFAULT_WAIT_TIMEOUT_MS: u64 = 15_000; /// Declarative scenario consumed by [`ScriptedScenarioRunner`]. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ScriptedScenario { /// Stable scenario name used for artifact directories and reports. pub name: String, #[serde(default)] pub description: String, #[serde(default)] pub terminal: TerminalConfig, #[serde(default)] pub environment: EnvironmentConfig, /// Optional ephemeral workspace materialized into a temp dir and used as the /// pager's cwd. Lets a scenario exercise repo-local behavior — e.g. the /// folder-trust prompt, which only renders when `cwd` has a repo-local /// config (`.mcp.json`). `None` inherits the test process cwd. #[serde(default)] pub workspace: Option, #[serde(default)] pub mock: MockConfig, #[serde(default)] pub steps: Vec, } impl ScriptedScenario { /// Load a scenario from JSON. pub fn from_json_file(path: &Path) -> Result { let text = fs::read_to_string(path) .with_context(|| format!("read scenario file {}", path.display()))?; serde_json::from_str(&text) .with_context(|| format!("parse scenario JSON {}", path.display())) } /// Load a scenario from YAML. pub fn from_yaml_file(path: &Path) -> Result { let text = fs::read_to_string(path) .with_context(|| format!("read scenario file {}", path.display()))?; serde_yaml::from_str(&text) .with_context(|| format!("parse scenario YAML {}", path.display())) } /// Load a scenario from JSON or YAML based on file extension. pub fn from_file(path: &Path) -> Result { match path.extension().and_then(|ext| ext.to_str()) { Some("yaml" | "yml") => Self::from_yaml_file(path), _ => Self::from_json_file(path), } } fn validate(&self) -> Result<()> { if self.name.trim().is_empty() { bail!("scenario name must not be empty"); } if self.steps.is_empty() { bail!("scenario {} has no steps", self.name); } Ok(()) } } /// Terminal dimensions for a scripted run. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TerminalConfig { #[serde(default = "default_rows")] pub rows: u16, #[serde(default = "default_cols")] pub cols: u16, /// Answer terminal device queries (cursor-position reports for `ESC[6n`, /// etc.) from the embedded vt100 emulator, like a real terminal would. /// Off by default — most scenarios don't need it. Required for `--minimal` /// scenarios: the inline viewport's startup cursor-position probe otherwise /// times out and `--minimal` silently downgrades to full-height inline /// (see `PtyHarness::set_respond_to_queries`). #[serde(default)] pub respond_to_queries: bool, } impl Default for TerminalConfig { fn default() -> Self { Self { rows: DEFAULT_ROWS, cols: DEFAULT_COLS, respond_to_queries: false, } } } /// Environment filters and extra environment variables for a scenario. #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct EnvironmentConfig { /// Optional OS allow-list. Values are Rust `std::env::consts::OS` strings /// such as `macos`, `linux`, or `windows`. #[serde(default)] pub os: Vec, /// Optional architecture allow-list. Values are /// `std::env::consts::ARCH` strings such as `aarch64` or `x86_64`. #[serde(default)] pub arch: Vec, /// Additional env vars set on the pager process. #[serde(default)] pub env: Vec, /// Extra CLI args passed to the pager binary. #[serde(default)] pub args: Vec, /// Optional `config.toml` written into the run's isolated `$KIGI_SHARE_DIR` /// before spawn (e.g. `[ui] keep_text_selection` so selection /// highlights survive long enough to assert on). #[serde(default)] pub config_toml: Option, } impl EnvironmentConfig { fn matches_current(&self) -> bool { (self.os.is_empty() || self.os.iter().any(|os| os == std::env::consts::OS)) && (self.arch.is_empty() || self.arch.iter().any(|arch| arch == std::env::consts::ARCH)) } } /// One environment variable assignment. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct EnvVar { pub key: String, pub value: String, } /// Ephemeral workspace materialized for a scenario run: a temp dir seeded with /// declared files and optionally `git init`-ed, then used as the pager's cwd. /// The folder-trust prompt, for example, only renders when `cwd` contains a /// repo-local config (`.mcp.json`), so a scenario can declare one here. #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct WorkspaceConfig { /// Run `git init` so the temp dir resolves as a git repository root — the /// scope repo-local config discovery and folder-trust use to bound a repo. #[serde(default)] pub git_init: bool, /// Files to create in the workspace, keyed by path relative to its root. /// Parent directories are created as needed. #[serde(default)] pub files: std::collections::BTreeMap, } /// Mock server configuration used to drive assistant output into the pager. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MockConfig { #[serde(default = "default_mock_response")] pub response: String, /// Optional per-agent-turn responses, consumed FIFO (one per real agent /// turn; aux requests don't consume one — see /// `MockInferenceServer::set_agent_turns`). Lets a scenario give each /// turn a distinct sentinel, e.g. to prove a transcript tail was /// truncated and re-generated. Falls back to `response` when exhausted. #[serde(default)] pub turns: Vec, #[serde(default)] pub fixture_images: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ImageFixture { pub name: String, #[serde(default = "default_fixture_extension")] pub extension: String, #[serde(default)] pub kind: ImageFixtureKind, } /// Variant of synthetic image to write for an [`ImageFixture`]. #[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum ImageFixtureKind { /// 8x8 RGBA PNG — meets the minimum vision-model dimension requirement. #[default] Standard, /// 1x1 RGBA PNG — below the 8 px minimum; rejected client-side. #[serde(rename = "tiny_1x1")] Tiny1x1, /// Valid 64x64 PNG header with a clobbered IDAT CRC; full decode rejects. CrcCorruptPng, } impl Default for MockConfig { fn default() -> Self { Self { response: default_mock_response(), turns: Vec::new(), fixture_images: Vec::new(), } } } /// A single executable scenario step. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "action", rename_all = "snake_case")] pub enum ScenarioStep { /// Wait until the screen contains text. WaitForText { text: String, #[serde(default = "default_wait_timeout_ms")] timeout_ms: u64, }, /// Assert text is present on the current screen. AssertContains { text: String }, /// Assert text is absent from the current screen. AssertNotContains { text: String }, /// Type literal text into the TUI. TypeText { text: String }, /// Inject keys using ptyctl notation, for example ``, ``, `jj`. Keys { keys: String }, /// Paste text using bracketed paste sequences. Paste { text: String }, /// Paste fixture image path(s), using the same path parser as terminal drag/drop. PasteImagePaths { images: Vec }, /// Paste fixture image file:// URL(s), matching Finder-style file URL payloads. PasteImageFileUrls { images: Vec }, /// Paste fixture image path(s) with shell escaping for spaces/special chars. PasteEscapedImagePaths { images: Vec }, /// Focus the prompt input by locating the rendered prompt box. FocusPrompt, /// Paste into the prompt using semantic prompt focus instead of coordinates. PastePrompt { text: String }, /// Simulate copying an image to the OS clipboard, then pressing paste. PasteClipboardImage { image: String }, /// Drop text into the prompt using semantic prompt focus instead of coordinates. DropTextPrompt { text: String }, /// Simulate a mouse click. Coordinates are 0-indexed. MouseClick { row: u16, col: u16, #[serde(default)] button: MouseButton, }, /// Simulate a double-click at one coordinate. DoubleClick { row: u16, col: u16 }, /// Simulate a triple-click at one coordinate. TripleClick { row: u16, col: u16 }, /// Simulate mouse-wheel scrolling at a coordinate. Scroll { row: u16, col: u16, direction: ScrollDirection, #[serde(default = "default_scroll_count")] count: u16, }, /// Scroll over a semantic target instead of coordinates. ScrollAt { target: TargetLocator, direction: ScrollDirection, #[serde(default = "default_scroll_count")] count: u16, }, /// Simulate a primary-button mouse drag. Coordinates are 0-indexed. Drag { from: MousePoint, to: MousePoint }, /// Alias for drag used by text-selection scenarios. SelectText { from: MousePoint, to: MousePoint }, /// Click visible text by finding it on screen. ClickText { text: String, #[serde(default)] occurrence: usize, #[serde(default)] button: MouseButton, }, /// Double-click visible text by finding it on screen. DoubleClickText { text: String, #[serde(default)] occurrence: usize, }, /// Triple-click visible text by finding it on screen. TripleClickText { text: String, #[serde(default)] occurrence: usize, }, /// Select a visible text range by locating both endpoints on screen. /// `to_offset_cols` shifts the drag's end column right (clamped at the /// screen edge), e.g. onto a table border when exercising dead-zones. SelectTextRange { from_text: String, to_text: String, #[serde(default)] to_offset_cols: u16, }, /// Simulate dropping text at a position with click + bracketed paste. DropText { row: u16, col: u16, text: String }, /// Simulate dropping text at a semantic target. DropTextAt { target: TargetLocator, text: String }, /// Simulate drag/drop of image files onto the prompt as terminal paste payload. DropImagesPrompt { images: Vec }, /// Trigger a copy shortcut/key sequence from the TUI. Copy { #[serde(default = "default_copy_keys")] keys: String, }, /// Assert the captured terminal output contains an OSC 52 clipboard write. AssertOsc52Contains { text: String }, /// Assert NO OSC 52 clipboard payload contains `text` (negative of /// [`AssertOsc52Contains`]). Used to prove a label prefix was excluded /// from a selection/copy, e.g. that copying a Read tool header yields the /// path alone rather than the full `Read {path}` line. AssertOsc52NotContains { text: String }, /// Assert the contiguous highlighted run at the first occurrence of /// `at_text` renders `equals`, untrimmed, over one uniform background. /// Needs a persistent `keep_text_selection` via `config_toml` (flash /// clears in ~150ms). AssertHighlightRun { at_text: String, equals: String }, /// Assert no screen cell rendering the first occurrence of `text` has a /// non-default background (the text is outside any selection highlight). AssertTextNotHighlighted { text: String }, /// Assert the captured **raw** PTY output contains at least `min` Kitty /// graphics APC sequences (`\x1b_G`). Mirrors [`AssertOsc52Contains`]: the /// graphics escapes are written into the synchronized-update frame buffer, /// outside the vt100 cell grid, so a screen-text snapshot can't see them — /// this scans the raw bytes instead. Proves an inline diagram was emitted. AssertKittyGraphics { #[serde(default = "default_assert_min")] min: usize, }, /// Poll the raw PTY output until at least `min` Kitty graphics APC sequences /// appear (or `timeout_ms` expires). Preferred over a fixed `wait` before /// [`AssertKittyGraphics`]: it returns as soon as the diagram is placed and /// doesn't flake under load. WaitForKittyGraphics { #[serde(default = "default_assert_min")] min: usize, #[serde(default = "default_wait_timeout_ms")] timeout_ms: u64, }, /// Assert the captured **raw** PTY output contains NO Kitty graphics APC /// sequences. The inverse of [`AssertKittyGraphics`]: proves a feature (e.g. /// a Mermaid diagram) was rendered as text, never transmitted as an inline /// image. AssertNoKittyGraphics {}, /// Assert that a submitted request included at least this many image payloads. AssertRequestImageCount { min: usize }, /// Assert every submitted image has inline bytes, the expected MIME type, and decodes. AssertInlineImages { min: usize, #[serde(default = "default_expected_image_mime")] mime_type: String, #[serde(default = "default_image_width")] width: DimensionAssertion, #[serde(default = "default_image_height")] height: DimensionAssertion, }, /// Assert at least one request body contains the literal `text`. AssertRequestContains { text: String, /// Optional — restrict to the Nth most recent request body (0 = newest). #[serde(default)] request_index: Option, }, /// Assert no `*.tmp` files remain anywhere under the mock content home directory. AssertNoTempArtifacts {}, /// Resize the terminal. Resize { rows: u16, cols: u16 }, /// Drain output for a fixed period. Wait { millis: u64 }, /// Capture text, HTML, SVG, and JSON artifacts for the current screen. Screenshot { name: String, #[serde(default)] note: String, }, /// Assert the pager process is still alive. AssertRunning, /// Assert the mock server received at least one chat completion request. AssertChatCompletion, } /// Dimension assertion for [`ScenarioStep::AssertInlineImages`]: either an /// exact pixel count (`width: 28`) or a range (`width: { min: 28 }`). #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(untagged)] pub enum DimensionAssertion { Exact(u32), Range { #[serde(default)] min: Option, #[serde(default)] max: Option, }, } impl DimensionAssertion { fn matches(&self, actual: u32) -> bool { match self { DimensionAssertion::Exact(v) => actual == *v, DimensionAssertion::Range { min, max } => { min.is_none_or(|m| actual >= m) && max.is_none_or(|m| actual <= m) } } } fn describe(&self) -> String { match self { DimensionAssertion::Exact(v) => v.to_string(), DimensionAssertion::Range { min, max } => match (min, max) { (Some(lo), Some(hi)) => format!("[{lo}..={hi}]"), (Some(lo), None) => format!("[>={lo}]"), (None, Some(hi)) => format!("[<={hi}]"), (None, None) => "[any]".to_owned(), }, } } } /// A 0-indexed terminal coordinate. #[derive(Debug, Clone, Copy, Serialize, Deserialize)] pub struct MousePoint { pub row: u16, pub col: u16, } #[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum MouseButton { #[default] Left, Middle, Right, } #[derive(Debug, Clone, Copy, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ScrollDirection { Up, Down, } /// Semantic locator for UI targets that should not require hard-coded coordinates. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum TargetLocator { Prompt, Text { text: String, #[serde(default)] occurrence: usize, }, Point { row: u16, col: u16, }, } /// Runner configuration for scripted scenarios. #[derive(Debug, Clone)] pub struct ScriptedRunConfig { pub binary: PathBuf, pub artifact_dir: PathBuf, } impl ScriptedRunConfig { pub fn new(binary: PathBuf, artifact_dir: PathBuf) -> Self { Self { binary, artifact_dir, } } pub fn auto(artifact_dir: impl Into) -> Result { Ok(Self::new(pager_binary()?, artifact_dir.into())) } } /// Runs declarative scenarios and writes regression artifacts. pub struct ScriptedScenarioRunner { config: ScriptedRunConfig, } impl ScriptedScenarioRunner { pub fn new(config: ScriptedRunConfig) -> Self { Self { config } } pub async fn run(&self, scenario: &ScriptedScenario) -> Result { scenario.validate()?; let started_at_ms = epoch_ms(); let run_dir = self .config .artifact_dir .join(safe_name(&scenario.name)) .join(started_at_ms.to_string()); fs::create_dir_all(&run_dir) .with_context(|| format!("create artifact directory {}", run_dir.display()))?; let fixture_paths = prepare_image_fixtures(&run_dir, &scenario.mock.fixture_images)?; let mut report = ScriptedRunReport::new(scenario, &run_dir, started_at_ms); if !scenario.environment.matches_current() { report.status = ScriptedRunStatus::Skipped; report.skip_reason = Some(format!( "current platform {}/{} is outside scenario environment filters", std::env::consts::OS, std::env::consts::ARCH )); report.write(&run_dir)?; return Ok(report); } let content = ContentController::start() .await .context("start mock content")?; content.set_response(&scenario.mock.response); if !scenario.mock.turns.is_empty() { content.set_turns(scenario.mock.turns.iter().cloned()); } if let Some(config_toml) = &scenario.environment.config_toml { let kigi_home = content.home().join(".kigi"); fs::create_dir_all(&kigi_home).with_context(|| { format!("create scenario KIGI_SHARE_DIR {}", kigi_home.display()) })?; fs::write(kigi_home.join("config.toml"), config_toml) .context("write scenario config.toml")?; } let mut env = content.env_for_pager(); env.extend( scenario .environment .env .iter() .map(|v| (v.key.clone(), v.value.clone())), ); let env_refs: Vec<(&str, &str)> = env .iter() .map(|(key, value)| (key.as_str(), value.as_str())) .collect(); let args: Vec<&str> = scenario .environment .args .iter() .map(String::as_str) .collect(); // Materialize an optional ephemeral workspace (temp dir + files + git // init) and run the pager there. Bound for the whole run so the dir // outlives the pager process; `None` inherits the test process cwd. let workspace_dir = match scenario.workspace.as_ref() { Some(ws) => Some(materialize_workspace(ws)?), None => None, }; let workspace_cwd = workspace_dir.as_ref().map(|dir| dir.path()); let mut harness = PtyHarness::new_in_dir( &self.config.binary, scenario.terminal.rows, scenario.terminal.cols, &args, &env_refs, workspace_cwd, ) .with_context(|| format!("spawn pager binary {}", self.config.binary.display()))?; harness.set_respond_to_queries(scenario.terminal.respond_to_queries); for (index, step) in scenario.steps.iter().enumerate() { let step_number = index + 1; let step_started = std::time::Instant::now(); match run_step( &mut harness, &content, &run_dir, &fixture_paths, step_number, step, ) { Ok(mut outcome) => { outcome.elapsed_ms = step_started.elapsed().as_millis() as u64; report.artifacts.extend(outcome.artifacts.clone()); report.steps.push(outcome); } Err(error) => { let bug = BugFinding { step: step_number, severity: BugSeverity::Bug, message: error.to_string(), screen_text: harness.screen_contents(), }; report.bugs.push(bug); report .steps .push(StepOutcome::failed(step_number, step, error.to_string())); let raw_path = run_dir.join("raw_output.bin"); let _ = fs::write(&raw_path, harness.raw_output()); let _ = capture_artifacts( &harness, &run_dir, step_number, "failure", "automatic capture after failed step", ) .map(|artifacts| report.artifacts.extend(artifacts)); report.status = ScriptedRunStatus::Failed; report.write(&run_dir)?; write_bug_markdown(&run_dir, &report)?; let _ = harness.quit(); return Ok(report); } } } if !harness.is_running() { report.bugs.push(BugFinding { step: scenario.steps.len(), severity: BugSeverity::Bug, message: "pager exited before scenario completed".to_owned(), screen_text: harness.screen_contents(), }); report.status = ScriptedRunStatus::Failed; } let _ = harness.quit(); if report.status == ScriptedRunStatus::Running { report.status = ScriptedRunStatus::Passed; } // Persist raw PTY bytes for offline analysis (e.g. Kitty a=t vs a=p counts). let raw_path = run_dir.join("raw_output.bin"); let _ = fs::write(&raw_path, harness.raw_output()); report.write(&run_dir)?; write_bug_markdown(&run_dir, &report)?; Ok(report) } } /// Create a temp dir for a scenario [`WorkspaceConfig`]: write its files /// (creating parent dirs) and optionally `git init` it. The returned `TempDir` /// must be held for the whole run so the directory outlives the pager process. fn materialize_workspace(workspace: &WorkspaceConfig) -> Result { let dir = tempfile::tempdir().context("create scenario workspace temp dir")?; for (rel_path, contents) in &workspace.files { // Fail closed: a `files` key must be a relative path that stays inside // the workspace. Reject absolute paths and any root/prefix/`..` // component so a scenario can never write outside the tempdir. This is // author-controlled test YAML (not a security boundary), but it's the // shared materialization path, so guard it. let rel = Path::new(rel_path); if rel.is_absolute() || rel.components().any(|c| { matches!( c, Component::ParentDir | Component::RootDir | Component::Prefix(_) ) }) { bail!("workspace file path must be relative and within the workspace: {rel_path}"); } let path = dir.path().join(rel); if let Some(parent) = path.parent() { fs::create_dir_all(parent) .with_context(|| format!("create workspace dir {}", parent.display()))?; } fs::write(&path, contents) .with_context(|| format!("write workspace file {}", path.display()))?; } if workspace.git_init { // A real repo root makes `workspace_key` / repo-local discovery // deterministic regardless of where the system temp dir lives. Shelled // out (not `git2`) because this harness crate has no `git2` dependency; // the subprocess is careful — nulled streams + `status.success()` check // + `bail!` on failure. let status = std::process::Command::new("git") .args(["init", "-q"]) .current_dir(dir.path()) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .status() .context("run `git init` for scenario workspace")?; if !status.success() { bail!("`git init` for scenario workspace failed: {status}"); } } Ok(dir) } fn run_step( harness: &mut PtyHarness, content: &ContentController, run_dir: &Path, fixture_paths: &std::collections::HashMap, step_number: usize, step: &ScenarioStep, ) -> Result { match step { ScenarioStep::WaitForText { text, timeout_ms } => harness .wait_for_text(text, Duration::from_millis(*timeout_ms)) .with_context(|| format!("wait for text {text:?}"))?, ScenarioStep::AssertContains { text } => { if !harness.contains_text(text) { bail!("expected screen to contain {text:?}"); } } ScenarioStep::AssertNotContains { text } => { if harness.contains_text(text) { bail!("expected screen not to contain {text:?}"); } } ScenarioStep::TypeText { text } => { harness .inject_keys(text.as_bytes()) .with_context(|| format!("type text {text:?}"))?; harness.update(Duration::from_millis(100)); } ScenarioStep::Keys { keys } => { let bytes = parse_keys(keys).map_err(|e| anyhow!("parse keys {keys:?}: {e}"))?; harness .inject_keys(&bytes) .with_context(|| format!("inject keys {keys:?}"))?; harness.update(Duration::from_millis(100)); } ScenarioStep::Paste { text } => { harness .inject_keys(bracketed_paste(text).as_bytes()) .with_context(|| format!("paste {} bytes", text.len()))?; harness.update(Duration::from_millis(250)); } ScenarioStep::PasteImagePaths { images } => { let text = image_path_payload(fixture_paths, images, PathPayloadStyle::Plain)?; paste_prompt_text(harness, &text).context("paste image path payload")?; } ScenarioStep::PasteImageFileUrls { images } => { let text = image_path_payload(fixture_paths, images, PathPayloadStyle::FileUrl)?; paste_prompt_text(harness, &text).context("paste image file URL payload")?; } ScenarioStep::PasteEscapedImagePaths { images } => { let text = image_path_payload(fixture_paths, images, PathPayloadStyle::Escaped)?; paste_prompt_text(harness, &text).context("paste escaped image path payload")?; } ScenarioStep::FocusPrompt => { click_point(harness, locate_prompt(harness)?, MouseButton::Left) .context("focus prompt")?; } ScenarioStep::PastePrompt { text } => { paste_prompt_text(harness, text).context("paste prompt text")?; } ScenarioStep::PasteClipboardImage { image } => { let text = image_path_payload( fixture_paths, std::slice::from_ref(image), PathPayloadStyle::Plain, )?; paste_prompt_text(harness, &text).context("paste clipboard image fixture")?; } ScenarioStep::DropTextPrompt { text } => { click_point( harness, locate_prompt_drop_point(harness)?, MouseButton::Left, ) .context("focus prompt before drop text")?; harness .inject_keys(bracketed_paste(text).as_bytes()) .with_context(|| format!("drop text {} bytes into prompt", text.len()))?; harness.update(Duration::from_millis(250)); } ScenarioStep::MouseClick { row, col, button } => { harness .inject_keys(mouse_click_bytes(*row, *col, *button).as_bytes()) .with_context(|| format!("mouse click at row={row} col={col}"))?; harness.update(Duration::from_millis(100)); } ScenarioStep::DoubleClick { row, col } => { harness .inject_keys(repeated_click_bytes(*row, *col, 2).as_bytes()) .with_context(|| format!("double click at row={row} col={col}"))?; harness.update(Duration::from_millis(150)); } ScenarioStep::TripleClick { row, col } => { harness .inject_keys(repeated_click_bytes(*row, *col, 3).as_bytes()) .with_context(|| format!("triple click at row={row} col={col}"))?; harness.update(Duration::from_millis(150)); } ScenarioStep::Scroll { row, col, direction, count, } => { harness .inject_keys(mouse_scroll_bytes(*row, *col, *direction, *count).as_bytes()) .with_context(|| format!("mouse scroll {direction:?} at row={row} col={col}"))?; harness.update(Duration::from_millis(150)); } ScenarioStep::ScrollAt { target, direction, count, } => { let point = locate_target(harness, target)?; harness .inject_keys( mouse_scroll_bytes(point.row, point.col, *direction, *count).as_bytes(), ) .with_context(|| format!("mouse scroll {direction:?} at {point:?}"))?; harness.update(Duration::from_millis(150)); } ScenarioStep::Drag { from, to } | ScenarioStep::SelectText { from, to } => { harness .inject_keys(mouse_drag_bytes(*from, *to).as_bytes()) .with_context(|| format!("drag from {from:?} to {to:?}"))?; harness.update(Duration::from_millis(100)); } ScenarioStep::ClickText { text, occurrence, button, } => { click_point(harness, locate_text(harness, text, *occurrence)?, *button) .with_context(|| format!("click text {text:?}"))?; } ScenarioStep::DoubleClickText { text, occurrence } => { let point = locate_text(harness, text, *occurrence)?; harness .inject_keys(repeated_click_bytes(point.row, point.col, 2).as_bytes()) .with_context(|| format!("double-click text {text:?}"))?; harness.update(Duration::from_millis(150)); } ScenarioStep::TripleClickText { text, occurrence } => { let point = locate_text(harness, text, *occurrence)?; harness .inject_keys(repeated_click_bytes(point.row, point.col, 3).as_bytes()) .with_context(|| format!("triple-click text {text:?}"))?; harness.update(Duration::from_millis(150)); } ScenarioStep::SelectTextRange { from_text, to_text, to_offset_cols, } => { let from = locate_text(harness, from_text, 0)?; let mut to = locate_text_end(harness, to_text, 0)?; if to.col == 0 { to.col = 1; } to.col = to .col .saturating_add(*to_offset_cols) .min(harness.screen_output().size.cols.saturating_sub(1) as u16); harness .inject_keys(mouse_drag_bytes(from, to).as_bytes()) .with_context(|| format!("select text range {from_text:?}..{to_text:?}"))?; harness.update(Duration::from_millis(150)); } ScenarioStep::DropText { row, col, text } => { harness .inject_keys(mouse_click_bytes(*row, *col, MouseButton::Left).as_bytes()) .with_context(|| format!("drop click at row={row} col={col}"))?; harness.update(Duration::from_millis(100)); harness .inject_keys(bracketed_paste(text).as_bytes()) .with_context(|| format!("drop text {} bytes", text.len()))?; harness.update(Duration::from_millis(250)); } ScenarioStep::DropTextAt { target, text } => { click_point(harness, locate_target(harness, target)?, MouseButton::Left) .context("focus drop target")?; harness .inject_keys(bracketed_paste(text).as_bytes()) .with_context(|| format!("drop text {} bytes", text.len()))?; harness.update(Duration::from_millis(250)); } ScenarioStep::DropImagesPrompt { images } => { let text = image_path_payload(fixture_paths, images, PathPayloadStyle::Plain)?; click_point( harness, locate_prompt_drop_point(harness)?, MouseButton::Left, ) .context("focus prompt before image drop")?; harness .inject_keys(bracketed_paste(&text).as_bytes()) .with_context(|| format!("drop image path payload {} bytes", text.len()))?; harness.update(Duration::from_millis(500)); } ScenarioStep::Copy { keys } => { let bytes = parse_keys(keys).map_err(|e| anyhow!("parse copy keys {keys:?}: {e}"))?; harness .inject_keys(&bytes) .with_context(|| format!("inject copy keys {keys:?}"))?; harness.update(Duration::from_millis(250)); } ScenarioStep::AssertOsc52Contains { text } => { let clipboards = decode_osc52_payloads(harness.raw_output())?; if !clipboards.iter().any(|payload| payload.contains(text)) { bail!( "expected OSC 52 clipboard payload to contain {text:?}; decoded payloads: {clipboards:?}" ); } } ScenarioStep::AssertOsc52NotContains { text } => { let clipboards = decode_osc52_payloads(harness.raw_output())?; if clipboards.iter().any(|payload| payload.contains(text)) { bail!( "expected no OSC 52 clipboard payload to contain {text:?}; decoded payloads: {clipboards:?}" ); } } ScenarioStep::AssertHighlightRun { at_text, equals } => { let point = locate_text(harness, at_text, 0)?; let cells = row_char_bgs(harness, point.row) .with_context(|| format!("styled row {} for {at_text:?}", point.row))?; // Highlighted = differs from the row's dominant background; the // hovered-entry wash tints whole rows, so bg-set alone is wrong. let dominant = dominant_bg(&cells); let col = point.col as usize; let highlighted = |idx: usize| cells.get(idx).is_some_and(|(_, bg)| *bg != dominant); if !highlighted(col) { bail!( "expected {at_text:?} (row {}, col {}) to be inside a selection highlight", point.row, point.col ); } let mut start = col; while start > 0 && highlighted(start - 1) { start -= 1; } let mut end = col + 1; while highlighted(end) { end += 1; } // Untrimmed: a highlight claiming padding or borders must fail. let run: String = cells[start..end].iter().map(|(ch, _)| *ch).collect(); if run != *equals { bail!("highlight run at {at_text:?} renders {run:?}, expected {equals:?}"); } // The run must be one uniform band, styled spans included. let bgs: std::collections::BTreeSet<_> = cells[start..end].iter().map(|(_, bg)| bg.clone()).collect(); if bgs.len() > 1 { bail!("highlight run at {at_text:?} is not uniform: backgrounds {bgs:?}"); } } ScenarioStep::AssertTextNotHighlighted { text } => { let point = locate_text(harness, text, 0)?; let cells = row_char_bgs(harness, point.row) .with_context(|| format!("styled row {} for {text:?}", point.row))?; let dominant = dominant_bg(&cells); let start = point.col as usize; let end = (start + text.chars().count()).min(cells.len()); for (idx, (_, bg)) in cells.iter().enumerate().take(end).skip(start) { if *bg != dominant { bail!( "expected {text:?} to be outside any selection highlight, but cell {idx} on row {} has background {bg:?} (row dominant: {dominant:?})", point.row ); } } } ScenarioStep::AssertKittyGraphics { min } => { let count = count_kitty_graphics(harness.raw_output()); if count < *min { bail!("expected at least {min} Kitty graphics escape(s), found {count}"); } } ScenarioStep::WaitForKittyGraphics { min, timeout_ms } => harness .wait_for_kitty_graphics(*min, Duration::from_millis(*timeout_ms)) .with_context(|| format!("wait for {min} Kitty graphics escape(s)"))?, ScenarioStep::AssertNoKittyGraphics {} => { let count = count_kitty_graphics(harness.raw_output()); if count > 0 { bail!("expected no Kitty graphics escapes, found {count}"); } } ScenarioStep::AssertRequestImageCount { min } => { let count = count_request_images(&content.request_bodies()); if count < *min { bail!("expected at least {min} request image(s), got {count}"); } } ScenarioStep::AssertInlineImages { min, mime_type, width, height, } => assert_inline_images(&content.request_bodies(), *min, mime_type, width, height)?, ScenarioStep::AssertRequestContains { text, request_index, } => assert_request_contains(&content.request_bodies(), text, *request_index)?, ScenarioStep::AssertNoTempArtifacts {} => assert_no_temp_artifacts(content.home())?, ScenarioStep::Resize { rows, cols } => harness .resize(*rows, *cols) .with_context(|| format!("resize to {rows}x{cols}"))?, ScenarioStep::Wait { millis } => harness.update(Duration::from_millis(*millis)), ScenarioStep::Screenshot { name, note } => { let artifacts = capture_artifacts(harness, run_dir, step_number, name, note)?; return Ok(StepOutcome::passed_with_artifacts( step_number, step, artifacts, )); } ScenarioStep::AssertRunning => { if !harness.is_running() { bail!("pager process is not running"); } } ScenarioStep::AssertChatCompletion => { if !content.has_chat_completion() { bail!("mock server did not receive a chat completion request"); } } } Ok(StepOutcome::passed(step_number, step)) } fn capture_artifacts( harness: &PtyHarness, run_dir: &Path, step_number: usize, name: &str, note: &str, ) -> Result> { let basename = format!("{:02}-{}", step_number, safe_name(name)); let text_path = run_dir.join(format!("{basename}.txt")); let html_path = run_dir.join(format!("{basename}.html")); let svg_path = run_dir.join(format!("{basename}.svg")); let json_path = run_dir.join(format!("{basename}.json")); let text = harness.screen_contents(); fs::write(&text_path, &text).with_context(|| format!("write {}", text_path.display()))?; let html = harness.screen_html(); fs::write(&html_path, html).with_context(|| format!("write {}", html_path.display()))?; let styled = harness.screen_styled(); let svg = render_svg(&styled, harness.cursor_position(), note); fs::write(&svg_path, svg).with_context(|| format!("write {}", svg_path.display()))?; let styled_json = serde_json::to_string_pretty(&styled).context("serialize styled screen")?; fs::write(&json_path, styled_json).with_context(|| format!("write {}", json_path.display()))?; Ok(vec![ VisualArtifact::new("text", text_path), VisualArtifact::new("html", html_path), VisualArtifact::new("svg", svg_path), VisualArtifact::new("styled_json", json_path), ]) } fn render_svg(lines: &[StyledLine], cursor: (u16, u16), note: &str) -> String { let cols = lines .iter() .flat_map(|line| line.runs.iter()) .map(|run| run.text.chars().count()) .max() .unwrap_or(1) .max(80); let rows = lines.len().max(1); let char_width = 8usize; let line_height = 18usize; let margin = 16usize; let note_height = if note.is_empty() { 0 } else { line_height + 8 }; let width = cols * char_width + margin * 2; let height = rows * line_height + margin * 2 + note_height; let mut svg = format!( "\n\n\n" ); if !note.is_empty() { svg.push_str(&format!( "{}\n", margin + 12, escape_xml(note) )); } let y_offset = margin + note_height; for (row, line) in lines.iter().enumerate() { let mut x = margin; let y = y_offset + row * line_height + 14; for run in &line.runs { let fill = run.fg.as_deref().unwrap_or("#d4d4d4"); let mut attrs = format!("fill=\"{}\"", escape_attr(fill)); if run.bold { attrs.push_str(" font-weight=\"700\""); } if run.italic { attrs.push_str(" font-style=\"italic\""); } if run.underline { attrs.push_str(" text-decoration=\"underline\""); } if let Some(bg) = &run.bg { let w = run.text.chars().count() * char_width; svg.push_str(&format!( "\n", y - 14, escape_attr(bg) )); } svg.push_str(&format!( "{}\n", escape_xml(&run.text) )); x += run.text.chars().count() * char_width; } } let cursor_row = cursor.0 as usize; let cursor_col = cursor.1 as usize; svg.push_str(&format!( "\n", margin + cursor_col * char_width, y_offset + cursor_row * line_height, )); svg.push_str("\n"); svg } /// Machine-readable run report written to `report.json`. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ScriptedRunReport { pub scenario: String, pub description: String, pub status: ScriptedRunStatus, pub skip_reason: Option, pub os: String, pub arch: String, pub started_at_ms: u128, pub artifact_dir: PathBuf, pub steps: Vec, pub artifacts: Vec, pub bugs: Vec, } impl ScriptedRunReport { fn new(scenario: &ScriptedScenario, artifact_dir: &Path, started_at_ms: u128) -> Self { Self { scenario: scenario.name.clone(), description: scenario.description.clone(), status: ScriptedRunStatus::Running, skip_reason: None, os: std::env::consts::OS.to_owned(), arch: std::env::consts::ARCH.to_owned(), started_at_ms, artifact_dir: artifact_dir.to_path_buf(), steps: Vec::new(), artifacts: Vec::new(), bugs: Vec::new(), } } fn write(&self, run_dir: &Path) -> Result<()> { let report_path = run_dir.join("report.json"); let json = serde_json::to_string_pretty(self).context("serialize report")?; fs::write(&report_path, json).with_context(|| format!("write {}", report_path.display())) } } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ScriptedRunStatus { Running, Passed, Failed, Skipped, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct StepOutcome { pub step: usize, pub action: String, pub status: StepStatus, pub message: Option, /// Wall-clock time this step took. For a leading `WaitForText` step this /// IS the pager's first-frame latency (spawn → text painted), which the /// CI perf budget reads (scripts/bench.sh). #[serde(default)] pub elapsed_ms: u64, #[serde(default)] pub artifacts: Vec, } impl StepOutcome { fn passed(step: usize, action: &ScenarioStep) -> Self { Self { step, action: action_name(action).to_owned(), status: StepStatus::Passed, message: None, elapsed_ms: 0, artifacts: Vec::new(), } } fn passed_with_artifacts( step: usize, action: &ScenarioStep, artifacts: Vec, ) -> Self { Self { artifacts, ..Self::passed(step, action) } } fn failed(step: usize, action: &ScenarioStep, message: String) -> Self { Self { step, action: action_name(action).to_owned(), status: StepStatus::Failed, message: Some(message), elapsed_ms: 0, artifacts: Vec::new(), } } } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum StepStatus { Passed, Failed, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct VisualArtifact { pub kind: String, pub path: PathBuf, } impl VisualArtifact { fn new(kind: impl Into, path: PathBuf) -> Self { Self { kind: kind.into(), path, } } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct BugFinding { pub step: usize, pub severity: BugSeverity, pub message: String, pub screen_text: String, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum BugSeverity { Bug, } fn write_bug_markdown(run_dir: &Path, report: &ScriptedRunReport) -> Result<()> { let path = run_dir.join("bugs.md"); let mut out = String::new(); out.push_str(&format!("# TUI Scenario Report: {}\n\n", report.scenario)); out.push_str(&format!("Status: {:?}\n\n", report.status)); out.push_str(&format!("Platform: {}/{}\n\n", report.os, report.arch)); if report.bugs.is_empty() { out.push_str("No bugs identified.\n"); } else { out.push_str("## Bugs\n\n"); for bug in &report.bugs { out.push_str(&format!( "- Step {}: {}\n\n```text\n{}\n```\n\n", bug.step, bug.message, bug.screen_text )); } } if !report.artifacts.is_empty() { out.push_str("## Visual artifacts\n\n"); for artifact in &report.artifacts { out.push_str(&format!( "- {}: {}\n", artifact.kind, artifact.path.display() )); } } fs::write(&path, out).with_context(|| format!("write {}", path.display())) } fn prepare_image_fixtures( run_dir: &Path, fixtures: &[ImageFixture], ) -> Result> { let dir = run_dir.join("fixtures"); fs::create_dir_all(&dir).with_context(|| format!("create fixture dir {}", dir.display()))?; let mut out = std::collections::HashMap::new(); for fixture in fixtures { let ext = fixture.extension.trim_start_matches('.'); let path = dir.join(format!("{}.{}", fixture.name, ext)); write_fixture_image(&path, fixture.kind) .with_context(|| format!("write image fixture {}", path.display()))?; let path = dunce::canonicalize(&path) .with_context(|| format!("canonicalize image fixture {}", path.display()))?; out.insert(fixture.name.clone(), path); } Ok(out) } fn write_fixture_image(path: &Path, kind: ImageFixtureKind) -> Result<()> { let bytes = match kind { ImageFixtureKind::Standard => standard_png_bytes()?, ImageFixtureKind::Tiny1x1 => tiny_1x1_png_bytes()?, ImageFixtureKind::CrcCorruptPng => crc_corrupt_png_bytes()?, }; fs::write(path, bytes)?; Ok(()) } fn standard_png_bytes() -> Result> { use image::{ImageBuffer, ImageFormat, Rgba}; let buffer: ImageBuffer, Vec> = ImageBuffer::from_pixel(8, 8, Rgba([128, 64, 32, 255])); let mut png = Vec::new(); buffer.write_to(&mut std::io::Cursor::new(&mut png), ImageFormat::Png)?; Ok(png) } fn tiny_1x1_png_bytes() -> Result> { use image::{ImageBuffer, ImageFormat, Rgba}; let buffer: ImageBuffer, Vec> = ImageBuffer::from_pixel(1, 1, Rgba([200, 100, 50, 255])); let mut png = Vec::new(); buffer.write_to(&mut std::io::Cursor::new(&mut png), ImageFormat::Png)?; Ok(png) } fn crc_corrupt_png_bytes() -> Result> { use image::{ImageBuffer, ImageFormat, Rgba}; let buffer: ImageBuffer, Vec> = ImageBuffer::from_pixel(64, 64, Rgba([10, 20, 30, 255])); let mut png = Vec::new(); buffer.write_to(&mut std::io::Cursor::new(&mut png), ImageFormat::Png)?; // Locate the IDAT chunk: [4-byte length][IDAT][...data...][4-byte CRC]. let idat = png .windows(4) .position(|w| w == b"IDAT") .ok_or_else(|| anyhow!("synthetic PNG missing IDAT chunk"))?; if idat < 4 { bail!("IDAT marker at offset {idat} has no length prefix"); } let length_bytes = &png[idat - 4..idat]; let data_len = u32::from_be_bytes([ length_bytes[0], length_bytes[1], length_bytes[2], length_bytes[3], ]) as usize; let crc_start = idat + 4 + data_len; let crc_end = crc_start + 4; if crc_end > png.len() { bail!( "IDAT CRC at {crc_start}..{crc_end} exceeds PNG length {}", png.len() ); } // Flip a single byte of the IDAT CRC. `image::load_from_memory` rejects the result. png[crc_start] ^= 0xFF; Ok(png) } fn fixture_path( fixture_paths: &std::collections::HashMap, name: &str, ) -> Result { fixture_paths .get(name) .cloned() .with_context(|| format!("unknown image fixture {name:?}")) } #[derive(Debug, Clone, Copy)] enum PathPayloadStyle { Plain, FileUrl, Escaped, } fn image_path_payload( fixture_paths: &std::collections::HashMap, images: &[String], style: PathPayloadStyle, ) -> Result { images .iter() .map(|name| { let path = fixture_path(fixture_paths, name)?; let s = match style { PathPayloadStyle::Plain => path.display().to_string(), PathPayloadStyle::FileUrl => format!("file://{}", path.display()), PathPayloadStyle::Escaped => shell_escape_path(&path), }; Ok(s) }) .collect::>>() .map(|parts| parts.join("\n")) } fn shell_escape_path(path: &Path) -> String { path.display() .to_string() .chars() .flat_map(|ch| match ch { ' ' | '(' | ')' | '[' | ']' | '\\' => vec!['\\', ch], _ => vec![ch], }) .collect() } fn paste_prompt_text(harness: &mut PtyHarness, text: &str) -> Result<()> { click_point(harness, locate_prompt(harness)?, MouseButton::Left) .context("focus prompt before paste")?; harness .inject_keys(bracketed_paste(text).as_bytes()) .with_context(|| format!("paste {} bytes into prompt", text.len()))?; harness.update(Duration::from_millis(500)); Ok(()) } fn count_request_images(bodies: &[serde_json::Value]) -> usize { request_images(bodies).len() } fn assert_inline_images( bodies: &[serde_json::Value], min: usize, expected_mime: &str, expected_width: &DimensionAssertion, expected_height: &DimensionAssertion, ) -> Result<()> { use base64::Engine as _; let images = request_images(bodies); if images.len() < min { bail!( "expected at least {min} inline image(s), got {}", images.len() ); } for (idx, image) in images.iter().enumerate() { let (mime, data) = inline_image_data(image) .with_context(|| format!("image {idx} missing inline image data: {image}"))?; if mime != expected_mime { bail!("image {idx} mime mismatch: expected {expected_mime}, got {mime}"); } if data.is_empty() { bail!("image {idx} has empty inline data"); } let bytes = base64::engine::general_purpose::STANDARD .decode(data) .with_context(|| format!("image {idx} data is not valid base64"))?; let (w, h) = image::ImageReader::new(std::io::Cursor::new(bytes)) .with_guessed_format() .with_context(|| format!("image {idx} format sniff failed"))? .into_dimensions() .with_context(|| format!("image {idx} did not decode"))?; if !expected_width.matches(w) { bail!( "image {idx} width {w} did not satisfy {}", expected_width.describe() ); } if !expected_height.matches(h) { bail!( "image {idx} height {h} did not satisfy {}", expected_height.describe() ); } } Ok(()) } fn assert_request_contains( bodies: &[serde_json::Value], needle: &str, request_index: Option, ) -> Result<()> { if bodies.is_empty() { bail!("expected request body containing {needle:?}, but no requests were captured"); } let haystacks: Vec = match request_index { Some(idx) => { let total = bodies.len(); if idx >= total { bail!( "request_index {idx} out of range (only {total} request body/bodies captured)" ); } let body = &bodies[total - 1 - idx]; vec![serde_json::to_string(body).unwrap_or_default()] } None => bodies .iter() .map(|b| serde_json::to_string(b).unwrap_or_default()) .collect(), }; if haystacks.iter().any(|s| s.contains(needle)) { return Ok(()); } bail!( "no request body contained {needle:?}; searched {} request(s)", haystacks.len() ); } fn assert_no_temp_artifacts(home: &Path) -> Result<()> { let mut offenders = Vec::new(); collect_tmp_files(home, &mut offenders)?; if !offenders.is_empty() { bail!( "found {} leftover *.tmp file(s) under {}: {:?}", offenders.len(), home.display(), offenders ); } Ok(()) } fn collect_tmp_files(dir: &Path, out: &mut Vec) -> Result<()> { let entries = match fs::read_dir(dir) { Ok(it) => it, Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()), Err(err) => return Err(err).with_context(|| format!("read_dir {}", dir.display())), }; for entry in entries { let entry = entry.with_context(|| format!("read_dir entry under {}", dir.display()))?; let ft = entry .file_type() .with_context(|| format!("file_type for {}", entry.path().display()))?; let path = entry.path(); if ft.is_dir() { collect_tmp_files(&path, out)?; } else if ft.is_file() && path .extension() .and_then(|e| e.to_str()) .is_some_and(|ext| ext.eq_ignore_ascii_case("tmp")) { out.push(path); } } Ok(()) } fn inline_image_data(image: &serde_json::Value) -> Option<(&str, &str)> { let mime = image .get("mime_type") .and_then(serde_json::Value::as_str) .or_else(|| image.get("mimeType").and_then(serde_json::Value::as_str)); let data = image.get("data").and_then(serde_json::Value::as_str); if let (Some(mime), Some(data)) = (mime, data) { return Some((mime, data)); } // The Responses API encodes the image as `image_url: "data:..."` // (string), while the legacy chat-completions shape uses // `image_url: { url: "data:..." }` (object). Accept both. let url = image .get("image_url") .and_then(|v| v.as_str().or_else(|| v.get("url").and_then(|u| u.as_str()))) .or_else(|| image.get("imageUrl").and_then(serde_json::Value::as_str))?; let rest = url.strip_prefix("data:")?; let (mime, encoded) = rest.split_once(";base64,")?; Some((mime, encoded)) } fn request_images(bodies: &[serde_json::Value]) -> Vec<&serde_json::Value> { let mut images = Vec::new(); for body in bodies { collect_images(body, &mut images); } images } fn collect_images<'a>(value: &'a serde_json::Value, images: &mut Vec<&'a serde_json::Value>) { match value { serde_json::Value::Object(map) => { let is_image = map .get("type") .and_then(serde_json::Value::as_str) .is_some_and(|ty| ty.eq_ignore_ascii_case("image") || ty.contains("image")) || map.contains_key("mime_type") && map.contains_key("data"); if is_image { images.push(value); } for child in map.values() { collect_images(child, images); } } serde_json::Value::Array(values) => { for child in values { collect_images(child, images); } } _ => {} } } fn locate_target(harness: &PtyHarness, target: &TargetLocator) -> Result { match target { TargetLocator::Prompt => locate_prompt(harness), TargetLocator::Text { text, occurrence } => locate_text(harness, text, *occurrence), TargetLocator::Point { row, col } => Ok(MousePoint { row: *row, col: *col, }), } } fn locate_prompt(harness: &PtyHarness) -> Result { let output = harness.screen_output(); for (row, line) in output.lines.iter().enumerate().rev() { let Some(marker_byte) = line.find('❯') else { continue; }; let col = line[..marker_byte].chars().count() + 2; return Ok(MousePoint { row: row as u16, col: col as u16, }); } bail!( "could not locate prompt marker `❯` on screen\n{}", harness.screen_contents() ) } fn locate_prompt_drop_point(harness: &PtyHarness) -> Result { let output = harness.screen_output(); for (row, line) in output.lines.iter().enumerate().rev() { let Some(marker_byte) = line.find('❯') else { continue; }; let after_marker = &line[marker_byte + '❯'.len_utf8()..]; let content_cols = after_marker.trim_end().chars().count(); let marker_col = line[..marker_byte].chars().count(); return Ok(MousePoint { row: row as u16, col: (marker_col + 2 + content_cols).min(line.chars().count().saturating_sub(1)) as u16, }); } bail!( "could not locate prompt marker `❯` on screen\n{}", harness.screen_contents() ) } /// Per-character backgrounds for a 0-indexed screen row (`None` = default), /// char-indexed to match [`locate_text`]; wide-char spacers are skipped by /// the styled extraction. fn row_char_bgs(harness: &PtyHarness, row: u16) -> Option)>> { let styled = harness.screen_styled(); let line = styled.iter().find(|l| l.line == row as usize + 1)?; let mut cells = Vec::new(); for run in &line.runs { for ch in run.text.chars() { cells.push((ch, run.bg.clone())); } } Some(cells) } /// The most common background on a row — its "unhighlighted" baseline /// (hovered/selected rows carry a uniform wash, not `None`). BTreeMap keys /// make count ties deterministic. fn dominant_bg(cells: &[(char, Option)]) -> Option { let mut counts: std::collections::BTreeMap<&Option, usize> = std::collections::BTreeMap::new(); for (_, bg) in cells { *counts.entry(bg).or_default() += 1; } counts .into_iter() .max_by_key(|&(bg, count)| (count, bg.clone())) .map(|(bg, _)| bg.clone()) .unwrap_or(None) } fn locate_text(harness: &PtyHarness, text: &str, occurrence: usize) -> Result { locate_text_impl(harness, text, occurrence, false) } fn locate_text_end(harness: &PtyHarness, text: &str, occurrence: usize) -> Result { let mut point = locate_text_impl(harness, text, occurrence, true)?; point.col = point.col.saturating_add(1); Ok(point) } fn locate_text_impl( harness: &PtyHarness, text: &str, occurrence: usize, end: bool, ) -> Result { if text.is_empty() { bail!("cannot locate empty text"); } let output = harness.screen_output(); let mut seen = 0usize; for (row, line) in output.lines.iter().enumerate() { let mut start_byte = 0usize; while let Some(rel_byte) = line[start_byte..].find(text) { let byte = start_byte + rel_byte; if seen == occurrence { let mut col = line[..byte].chars().count(); if end { col += text.chars().count().saturating_sub(1); } return Ok(MousePoint { row: row as u16, col: col as u16, }); } seen += 1; start_byte = byte + text.len(); } } bail!( "could not locate occurrence {occurrence} of text {text:?} on screen\n{}", harness.screen_contents() ) } fn click_point(harness: &mut PtyHarness, point: MousePoint, button: MouseButton) -> Result<()> { harness .inject_keys(mouse_click_bytes(point.row, point.col, button).as_bytes()) .with_context(|| format!("click at {point:?}"))?; harness.update(Duration::from_millis(100)); Ok(()) } fn decode_osc52_payloads(bytes: &[u8]) -> Result> { use base64::Engine as _; let output = String::from_utf8_lossy(bytes); let mut payloads = Vec::new(); for segment in output.split("\x1b]52;").skip(1) { let Some((_, rest)) = segment.split_once(';') else { continue; }; let end = rest.find(['\x07', '\x1b']).unwrap_or(rest.len()); let encoded = &rest[..end]; if encoded.is_empty() { continue; } let decoded = base64::engine::general_purpose::STANDARD .decode(encoded) .with_context(|| "decode OSC 52 base64 payload")?; let text = String::from_utf8(decoded).context("OSC 52 payload is not UTF-8 text")?; payloads.push(text); } Ok(payloads) } /// Count Kitty graphics protocol APC sequences (`ESC _ G`) that carry image /// data or placement in raw PTY output — i.e. every graphics APC *except* the /// pure control escapes delete (`a=d`) and capability query (`a=q`), which /// display nothing. (Image transmits chunk into several APCs, so this counts /// each chunk; callers use it as a presence test, not an exact image count.) /// /// The pager writes these into the synchronized-update frame buffer, outside the /// vt100 cell grid, so the screen-text snapshot can't observe them. Excluding /// delete/query makes `assert_no_kitty_graphics` mean "no inline image was /// shown", not "the pager never probed for graphics support". pub(crate) fn count_kitty_graphics(bytes: &[u8]) -> usize { const INTRO: &[u8] = b"\x1b_G"; let mut count = 0; let mut i = 0; while i + INTRO.len() <= bytes.len() { if &bytes[i..i + INTRO.len()] != INTRO { i += 1; continue; } // The APC's parameter section runs up to the first `;` (data follows) or // the terminating ESC. let params_start = i + INTRO.len(); let params_end = bytes[params_start..] .iter() .position(|&b| b == b';' || b == 0x1b) .map_or(bytes.len(), |p| params_start + p); let params = &bytes[params_start..params_end]; let has = |needle: &[u8]| params.windows(needle.len()).any(|w| w == needle); if !has(b"a=d") && !has(b"a=q") { count += 1; } i = params_start; } count } fn bracketed_paste(text: &str) -> String { format!("\x1b[200~{text}\x1b[201~") } fn mouse_click_bytes(row: u16, col: u16, button: MouseButton) -> String { let button_code = match button { MouseButton::Left => SGR_LEFT_BUTTON, MouseButton::Middle => SGR_MIDDLE_BUTTON, MouseButton::Right => SGR_RIGHT_BUTTON, }; format!( "{}{}", sgr_mouse(button_code, row, col, 'M'), sgr_mouse(button_code, row, col, 'm') ) } fn repeated_click_bytes(row: u16, col: u16, count: u8) -> String { let mut out = String::new(); for _ in 0..count { out.push_str(&mouse_click_bytes(row, col, MouseButton::Left)); } out } fn mouse_scroll_bytes(row: u16, col: u16, direction: ScrollDirection, count: u16) -> String { let code = match direction { ScrollDirection::Up => SGR_SCROLL_UP, ScrollDirection::Down => SGR_SCROLL_DOWN, }; let mut out = String::new(); for _ in 0..count { out.push_str(&sgr_mouse(code, row, col, 'M')); } out } fn mouse_drag_bytes(from: MousePoint, to: MousePoint) -> String { let mut out = String::new(); out.push_str(&sgr_mouse(SGR_LEFT_BUTTON, from.row, from.col, 'M')); let mid_row = (from.row + to.row) / 2; let mid_col = (from.col + to.col) / 2; out.push_str(&sgr_mouse(SGR_DRAG_BUTTON, mid_row, mid_col, 'M')); out.push_str(&sgr_mouse(SGR_DRAG_BUTTON, to.row, to.col, 'M')); out.push_str(&sgr_mouse(SGR_LEFT_BUTTON, to.row, to.col, 'm')); out } fn sgr_mouse(button: u16, row: u16, col: u16, suffix: char) -> String { format!("\x1b[<{button};{};{}{suffix}", col + 1, row + 1) } fn action_name(step: &ScenarioStep) -> &'static str { match step { ScenarioStep::WaitForText { .. } => "wait_for_text", ScenarioStep::AssertContains { .. } => "assert_contains", ScenarioStep::AssertNotContains { .. } => "assert_not_contains", ScenarioStep::TypeText { .. } => "type_text", ScenarioStep::Keys { .. } => "keys", ScenarioStep::Paste { .. } => "paste", ScenarioStep::PasteImagePaths { .. } => "paste_image_paths", ScenarioStep::PasteImageFileUrls { .. } => "paste_image_file_urls", ScenarioStep::PasteEscapedImagePaths { .. } => "paste_escaped_image_paths", ScenarioStep::FocusPrompt => "focus_prompt", ScenarioStep::PastePrompt { .. } => "paste_prompt", ScenarioStep::PasteClipboardImage { .. } => "paste_clipboard_image", ScenarioStep::DropTextPrompt { .. } => "drop_text_prompt", ScenarioStep::MouseClick { .. } => "mouse_click", ScenarioStep::DoubleClick { .. } => "double_click", ScenarioStep::TripleClick { .. } => "triple_click", ScenarioStep::Scroll { .. } => "scroll", ScenarioStep::ScrollAt { .. } => "scroll_at", ScenarioStep::Drag { .. } => "drag", ScenarioStep::SelectText { .. } => "select_text", ScenarioStep::ClickText { .. } => "click_text", ScenarioStep::DoubleClickText { .. } => "double_click_text", ScenarioStep::TripleClickText { .. } => "triple_click_text", ScenarioStep::SelectTextRange { .. } => "select_text_range", ScenarioStep::DropText { .. } => "drop_text", ScenarioStep::DropTextAt { .. } => "drop_text_at", ScenarioStep::DropImagesPrompt { .. } => "drop_images_prompt", ScenarioStep::Copy { .. } => "copy", ScenarioStep::AssertOsc52Contains { .. } => "assert_osc52_contains", ScenarioStep::AssertOsc52NotContains { .. } => "assert_osc52_not_contains", ScenarioStep::AssertHighlightRun { .. } => "assert_highlight_run", ScenarioStep::AssertTextNotHighlighted { .. } => "assert_text_not_highlighted", ScenarioStep::AssertKittyGraphics { .. } => "assert_kitty_graphics", ScenarioStep::WaitForKittyGraphics { .. } => "wait_for_kitty_graphics", ScenarioStep::AssertNoKittyGraphics {} => "assert_no_kitty_graphics", ScenarioStep::AssertRequestImageCount { .. } => "assert_request_image_count", ScenarioStep::AssertInlineImages { .. } => "assert_inline_images", ScenarioStep::AssertRequestContains { .. } => "assert_request_contains", ScenarioStep::AssertNoTempArtifacts {} => "assert_no_temp_artifacts", ScenarioStep::Resize { .. } => "resize", ScenarioStep::Wait { .. } => "wait", ScenarioStep::Screenshot { .. } => "screenshot", ScenarioStep::AssertRunning => "assert_running", ScenarioStep::AssertChatCompletion => "assert_chat_completion", } } fn safe_name(name: &str) -> String { let mut out = String::new(); for ch in name.chars() { if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' { out.push(ch); } else if !out.ends_with('-') { out.push('-'); } } out.trim_matches('-').to_owned() } fn escape_xml(text: &str) -> String { text.chars() .map(|ch| match ch { '&' => "&".to_owned(), '<' => "<".to_owned(), '>' => ">".to_owned(), '"' => """.to_owned(), '\'' => "'".to_owned(), _ => ch.to_string(), }) .collect() } fn escape_attr(text: &str) -> String { escape_xml(text) } fn epoch_ms() -> u128 { SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_millis() } fn default_rows() -> u16 { DEFAULT_ROWS } fn default_cols() -> u16 { DEFAULT_COLS } fn default_wait_timeout_ms() -> u64 { DEFAULT_WAIT_TIMEOUT_MS } fn default_scroll_count() -> u16 { 1 } fn default_assert_min() -> usize { 1 } fn default_copy_keys() -> String { "y".to_owned() } fn default_mock_response() -> String { "Hello from scripted TUI scenario mock.".to_owned() } fn default_fixture_extension() -> String { "png".to_owned() } fn default_expected_image_mime() -> String { "image/png".to_owned() } fn default_image_width() -> DimensionAssertion { DimensionAssertion::Exact(2) } fn default_image_height() -> DimensionAssertion { DimensionAssertion::Exact(2) } #[cfg(test)] mod tests { use super::*; #[test] fn parses_minimal_scenario() { let scenario: ScriptedScenario = serde_json::from_str( r#"{ "name": "welcome", "steps": [ {"action": "wait_for_text", "text": "Quit"}, {"action": "screenshot", "name": "welcome"} ] }"#, ) .expect("parse scenario"); assert_eq!(scenario.name, "welcome"); assert_eq!(scenario.terminal.rows, DEFAULT_ROWS); assert_eq!(scenario.steps.len(), 2); } #[test] fn safe_names_are_filesystem_friendly() { assert_eq!(safe_name("Welcome screen / macOS"), "Welcome-screen-macOS"); } #[test] fn workspace_rejects_paths_outside_the_tempdir() { use std::collections::BTreeMap; // A normal relative key (the folder-trust scenario's own `.mcp.json`) // is accepted. let ok = WorkspaceConfig { git_init: false, files: BTreeMap::from([(".mcp.json".to_string(), "{}".to_string())]), }; assert!(materialize_workspace(&ok).is_ok()); // Absolute and `..`-traversing keys are rejected before any write. for bad in ["/etc/evil", "../escape", "sub/../../escape"] { let ws = WorkspaceConfig { git_init: false, files: BTreeMap::from([(bad.to_string(), "x".to_string())]), }; let err = materialize_workspace(&ws).unwrap_err().to_string(); assert!( err.contains("must be relative and within the workspace"), "path {bad:?} must be rejected, got: {err}" ); } } #[test] fn image_fixture_defaults_to_standard_kind() { let f: ImageFixture = serde_yaml::from_str("name: legacy\nextension: png\n").expect("parse fixture"); assert_eq!(f.kind, ImageFixtureKind::Standard); } #[test] fn image_fixture_parses_explicit_kind() { let f: ImageFixture = serde_yaml::from_str("name: tiny\nkind: tiny_1x1\n").expect("parse"); assert_eq!(f.kind, ImageFixtureKind::Tiny1x1); let f: ImageFixture = serde_yaml::from_str("name: bad\nkind: crc_corrupt_png\n").expect("parse"); assert_eq!(f.kind, ImageFixtureKind::CrcCorruptPng); } fn decoded_dimensions(bytes: &[u8]) -> (u32, u32) { image::ImageReader::new(std::io::Cursor::new(bytes)) .with_guessed_format() .expect("sniff format") .into_dimensions() .expect("read dimensions") } #[test] fn standard_fixture_decodes_as_8x8() { let bytes = standard_png_bytes().expect("encode standard"); assert_eq!(decoded_dimensions(&bytes), (8, 8)); } #[test] fn tiny_fixture_decodes_as_1x1() { let bytes = tiny_1x1_png_bytes().expect("encode tiny"); assert_eq!(decoded_dimensions(&bytes), (1, 1)); } #[test] fn crc_corrupt_png_fails_to_decode() { let bytes = crc_corrupt_png_bytes().expect("encode corrupt"); assert!( image::load_from_memory(&bytes).is_err(), "expected CRC-corrupt PNG to be rejected by image::load_from_memory" ); } #[test] fn dimension_assertion_parses_exact_and_range() { let exact: DimensionAssertion = serde_yaml::from_str("28").expect("parse exact"); assert!(matches!(exact, DimensionAssertion::Exact(28))); assert!(exact.matches(28)); assert!(!exact.matches(29)); let lo: DimensionAssertion = serde_yaml::from_str("min: 28").expect("parse min"); assert!(lo.matches(28)); assert!(lo.matches(1024)); assert!(!lo.matches(27)); let hi: DimensionAssertion = serde_yaml::from_str("max: 100").expect("parse max"); assert!(hi.matches(0)); assert!(hi.matches(100)); assert!(!hi.matches(101)); let both: DimensionAssertion = serde_yaml::from_str("{min: 10, max: 20}").expect("parse range"); assert!(both.matches(15)); assert!(!both.matches(9)); assert!(!both.matches(21)); } #[test] fn assert_request_contains_finds_substring_in_any_body() { let bodies = vec![ serde_json::json!({"messages": [{"content": "hello"}]}), serde_json::json!({"messages": [{"content": "boom"}]}), ]; assert_request_contains(&bodies, "", None).expect("found"); assert!(assert_request_contains(&bodies, "no-such", None).is_err()); } #[test] fn assert_request_contains_honors_request_index() { let bodies = vec![ serde_json::json!({"k": "first"}), serde_json::json!({"k": "second"}), serde_json::json!({"k": "third"}), ]; // index 0 = newest assert_request_contains(&bodies, "third", Some(0)).expect("newest"); assert_request_contains(&bodies, "first", Some(2)).expect("oldest"); assert!(assert_request_contains(&bodies, "first", Some(0)).is_err()); assert!(assert_request_contains(&bodies, "anything", Some(99)).is_err()); } #[test] fn assert_no_temp_artifacts_scans_recursively() { let dir = tempfile::tempdir().expect("tempdir"); assert_no_temp_artifacts(dir.path()).expect("empty dir is clean"); let nested = dir.path().join("sub/sub2"); fs::create_dir_all(&nested).expect("mkdirs"); fs::write(nested.join("ok.png"), b"x").expect("write ok"); assert_no_temp_artifacts(dir.path()).expect("non-tmp file is clean"); fs::write(nested.join("leftover.tmp"), b"x").expect("write tmp"); assert!(assert_no_temp_artifacts(dir.path()).is_err()); } #[test] fn parses_assert_no_temp_artifacts_action() { let step: ScenarioStep = serde_yaml::from_str("action: assert_no_temp_artifacts").expect("parse"); assert!(matches!(step, ScenarioStep::AssertNoTempArtifacts {})); } #[test] fn parses_assert_request_contains_action() { let step: ScenarioStep = serde_yaml::from_str("action: assert_request_contains\ntext: hello").expect("parse"); match step { ScenarioStep::AssertRequestContains { text, request_index, } => { assert_eq!(text, "hello"); assert_eq!(request_index, None); } other => panic!("unexpected step: {other:?}"), } } #[test] fn count_kitty_graphics_counts_apc_introducers() { // Two image escapes (transmit `_Gf=100...` + place `_Ga=p...`), // ignoring OSC and other escapes around them. let raw = b"text\x1b_Gf=100,i=2;AAAA\x1b\\\x1b[0m\x1b_Ga=p,i=2\x1b\\more"; assert_eq!(count_kitty_graphics(raw), 2); assert_eq!(count_kitty_graphics(b"no graphics here"), 0); // An OSC 52 clipboard write must not be miscounted as a graphics APC. assert_eq!(count_kitty_graphics(b"\x1b]52;c;AAAA\x07"), 0); // Delete (`a=d`) and capability query (`a=q`) display nothing, so they // are not counted — a diagram that renders as text emits only these. assert_eq!(count_kitty_graphics(b"\x1b_Ga=d,d=i,i=1,q=2\x1b\\"), 0); assert_eq!( count_kitty_graphics(b"\x1b_Gi=31,a=q,s=1,v=1;AAAA\x1b\\"), 0 ); // A real place alongside a delete counts only the place. assert_eq!( count_kitty_graphics(b"\x1b_Ga=d,i=1\x1b\\\x1b_Ga=p,i=2\x1b\\"), 1 ); } #[test] fn parses_assert_kitty_graphics_with_default_min() { let step: ScenarioStep = serde_yaml::from_str("action: assert_kitty_graphics\n").expect("parse"); match step { ScenarioStep::AssertKittyGraphics { min } => assert_eq!(min, 1), other => panic!("unexpected step: {other:?}"), } let step: ScenarioStep = serde_yaml::from_str("action: assert_kitty_graphics\nmin: 3\n").expect("parse"); match step { ScenarioStep::AssertKittyGraphics { min } => assert_eq!(min, 3), other => panic!("unexpected step: {other:?}"), } } #[test] fn parses_assert_no_kitty_graphics() { let step: ScenarioStep = serde_yaml::from_str("action: assert_no_kitty_graphics\n").expect("parse"); assert!(matches!(step, ScenarioStep::AssertNoKittyGraphics {})); assert_eq!(action_name(&step), "assert_no_kitty_graphics"); } #[test] fn parses_assert_inline_images_with_min_form() { let step: ScenarioStep = serde_yaml::from_str( "action: assert_inline_images\nmin: 1\nwidth: { min: 28 }\nheight: { min: 28 }\n", ) .expect("parse"); match step { ScenarioStep::AssertInlineImages { width, height, .. } => { assert!(width.matches(28)); assert!(height.matches(64)); assert!(!width.matches(27)); } other => panic!("unexpected step: {other:?}"), } } #[test] fn assert_inline_images_accepts_min_form() { use base64::Engine as _; let encoded = base64::engine::general_purpose::STANDARD.encode(standard_png_bytes().unwrap()); let bodies = vec![serde_json::json!({ "messages": [{ "content": [{ "type": "image_url", "image_url": {"url": format!("data:image/png;base64,{encoded}")} }] }] })]; // exact form still works (default behaviour) assert_inline_images( &bodies, 1, "image/png", &DimensionAssertion::Exact(8), &DimensionAssertion::Exact(8), ) .expect("exact match"); // range form succeeds when width >= 1 assert_inline_images( &bodies, 1, "image/png", &DimensionAssertion::Range { min: Some(1), max: None, }, &DimensionAssertion::Range { min: Some(1), max: None, }, ) .expect("range match"); // range form fails when width must be >= 28 but actual is 8 assert!( assert_inline_images( &bodies, 1, "image/png", &DimensionAssertion::Range { min: Some(28), max: None }, &DimensionAssertion::Range { min: Some(28), max: None }, ) .is_err() ); } }