- 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.
2302 lines
83 KiB
Rust
2302 lines
83 KiB
Rust
//! 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 {
|
||
'&' => "&".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": "<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()
|
||
);
|
||
}
|
||
}
|