Files
Kigi-CLI/crates/codegen/kigi-pager-pty-harness/src/scripted.rs
T
ZacharyZhang-NY 28050e8e75 F9: smoke checklist + performance budgets in CI
- docs/SMOKE.md: the F9 capability checklist — every carried-over harness
  capability mapped to at least one automated case (test target) or a
  manual probe with its observable, per the PRD acceptance rule.
- scripts/bench.sh: enforces both PRD performance budgets and fails CI on
  a miss — `kigi --version` p95 <= 50ms via hyperfine, and TUI first
  frame <= 300ms measured through the pty harness (real vt100 emulator
  answering terminal queries) via the new scripts/first-frame.scenario.json.
  Local run: p95 10.1ms, first frame 134ms.
- pty harness: StepOutcome gains elapsed_ms (stamped per step by the
  scripted runner) — a leading wait_for_text step's elapsed IS the
  spawn-to-first-paint latency the budget reads.
- CI: new `perf` job (macOS + Linux) building the release binaries and
  running scripts/bench.sh.
2026-07-17 20:50:54 -04:00

2302 lines
83 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<WorkspaceConfig>,
#[serde(default)]
pub mock: MockConfig,
#[serde(default)]
pub steps: Vec<ScenarioStep>,
}
impl ScriptedScenario {
/// Load a scenario from JSON.
pub fn from_json_file(path: &Path) -> Result<Self> {
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<Self> {
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<Self> {
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<String>,
/// Optional architecture allow-list. Values are
/// `std::env::consts::ARCH` strings such as `aarch64` or `x86_64`.
#[serde(default)]
pub arch: Vec<String>,
/// Additional env vars set on the pager process.
#[serde(default)]
pub env: Vec<EnvVar>,
/// Extra CLI args passed to the pager binary.
#[serde(default)]
pub args: Vec<String>,
/// 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<String>,
}
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<String, String>,
}
/// 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<String>,
#[serde(default)]
pub fixture_images: Vec<ImageFixture>,
}
#[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 `<Enter>`, `<Esc>`, `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<String> },
/// Paste fixture image file:// URL(s), matching Finder-style file URL payloads.
PasteImageFileUrls { images: Vec<String> },
/// Paste fixture image path(s) with shell escaping for spaces/special chars.
PasteEscapedImagePaths { images: Vec<String> },
/// 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<String> },
/// 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<usize>,
},
/// 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<u32>,
#[serde(default)]
max: Option<u32>,
},
}
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<PathBuf>) -> Result<Self> {
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<ScriptedRunReport> {
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<tempfile::TempDir> {
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<String, PathBuf>,
step_number: usize,
step: &ScenarioStep,
) -> Result<StepOutcome> {
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<Vec<VisualArtifact>> {
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!(
"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{width}\" height=\"{height}\" viewBox=\"0 0 {width} {height}\">\n<rect width=\"100%\" height=\"100%\" fill=\"#1e1e1e\"/>\n<style>text {{ font-family: Menlo, Monaco, Consolas, monospace; font-size: 14px; white-space: pre; }}</style>\n"
);
if !note.is_empty() {
svg.push_str(&format!(
"<text x=\"{margin}\" y=\"{}\" fill=\"#9cdcfe\">{}</text>\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!(
"<rect x=\"{x}\" y=\"{}\" width=\"{w}\" height=\"{line_height}\" fill=\"{}\"/>\n",
y - 14,
escape_attr(bg)
));
}
svg.push_str(&format!(
"<text x=\"{x}\" y=\"{y}\" {attrs}>{}</text>\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!(
"<rect x=\"{}\" y=\"{}\" width=\"{char_width}\" height=\"{line_height}\" fill=\"none\" stroke=\"#ffffff\" stroke-width=\"1\"/>\n",
margin + cursor_col * char_width,
y_offset + cursor_row * line_height,
));
svg.push_str("</svg>\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<String>,
pub os: String,
pub arch: String,
pub started_at_ms: u128,
pub artifact_dir: PathBuf,
pub steps: Vec<StepOutcome>,
pub artifacts: Vec<VisualArtifact>,
pub bugs: Vec<BugFinding>,
}
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<String>,
/// 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<VisualArtifact>,
}
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<VisualArtifact>,
) -> 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<String>, 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<std::collections::HashMap<String, PathBuf>> {
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<Vec<u8>> {
use image::{ImageBuffer, ImageFormat, Rgba};
let buffer: ImageBuffer<Rgba<u8>, Vec<u8>> =
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<Vec<u8>> {
use image::{ImageBuffer, ImageFormat, Rgba};
let buffer: ImageBuffer<Rgba<u8>, Vec<u8>> =
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<Vec<u8>> {
use image::{ImageBuffer, ImageFormat, Rgba};
let buffer: ImageBuffer<Rgba<u8>, Vec<u8>> =
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<String, PathBuf>,
name: &str,
) -> Result<PathBuf> {
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<String, PathBuf>,
images: &[String],
style: PathPayloadStyle,
) -> Result<String> {
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::<Result<Vec<_>>>()
.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<usize>,
) -> Result<()> {
if bodies.is_empty() {
bail!("expected request body containing {needle:?}, but no requests were captured");
}
let haystacks: Vec<String> = 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<PathBuf>) -> 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<MousePoint> {
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<MousePoint> {
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<MousePoint> {
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<Vec<(char, Option<String>)>> {
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<String>)]) -> Option<String> {
let mut counts: std::collections::BTreeMap<&Option<String>, 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<MousePoint> {
locate_text_impl(harness, text, occurrence, false)
}
fn locate_text_end(harness: &PtyHarness, text: &str, occurrence: usize) -> Result<MousePoint> {
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<MousePoint> {
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<Vec<String>> {
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 {
'&' => "&amp;".to_owned(),
'<' => "&lt;".to_owned(),
'>' => "&gt;".to_owned(),
'"' => "&quot;".to_owned(),
'\'' => "&apos;".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": "<image_dropped_notice>boom"}]}),
];
assert_request_contains(&bodies, "<image_dropped_notice>", 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()
);
}
}