M0: compilable skeleton — Kigi 0.1.0 fork surgery
Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.
Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
ptyctl, ptyctl-cli, third_party/ unchanged; proto package
xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
(templates re-encrypted)
Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
module & dc_log, heap-profile uploader, auth-diagnostics uploader,
session-analytics halves of feedback; local zero-egress observability
preserved in new kigi-log crate (unified log, --debug firehose,
subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
shell util
Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted
Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean
Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
(new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
fast-worktree); RSS measurement tests serialized via serial_test
Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
notices sustained; kigi-tools ported-code notices extended; README,
CONTRIBUTING, SECURITY, AGENTS.md rewritten
Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
[package]
|
||||
name = "kigi-pager-pty-harness"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license = "Apache-2.0"
|
||||
authors = ["xAI"]
|
||||
description = "Shared PTY harness + scenario library for kigi-tui e2e tests and benchmarks."
|
||||
publish = false
|
||||
|
||||
# Not imported into the production pager binary — the pager's Cargo.toml
|
||||
# only references this crate via [dev-dependencies], so `cargo build -p
|
||||
# kigi-tui` never compiles any of this.
|
||||
|
||||
[dependencies]
|
||||
# Headless PTY controller with alacritty_terminal-based terminal emulation,
|
||||
# vim key notation parser, styled/HTML output, and HTTP server.
|
||||
dunce = { workspace = true }
|
||||
ptyctl = { path = "../ptyctl" }
|
||||
|
||||
# PTY spawning + input/output
|
||||
portable-pty = { workspace = true }
|
||||
# Real OS signals (libc::kill) for the SIGINT/SIGTERM/SIGHUP e2e path.
|
||||
libc = { workspace = true }
|
||||
|
||||
# alacritty_terminal re-exports `vte` which timing.rs uses for frame marker
|
||||
# detection. screen.rs uses ptyctl's Terminal (backed by alacritty_terminal)
|
||||
# instead of the old vt100 dependency.
|
||||
alacritty_terminal = { workspace = true }
|
||||
|
||||
# Shared mock inference server used to drive content into the pager
|
||||
# during scenarios (content.rs wraps it).
|
||||
kigi-test-support = { workspace = true }
|
||||
|
||||
# Async runtime + common helpers shared with the pager.
|
||||
tokio = { workspace = true, features = [
|
||||
"sync",
|
||||
"rt",
|
||||
"macros",
|
||||
"rt-multi-thread",
|
||||
"io-util",
|
||||
"io-std",
|
||||
"time",
|
||||
"net",
|
||||
] }
|
||||
|
||||
anyhow = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
serde_yaml = { workspace = true }
|
||||
image = { workspace = true, features = ["png"] }
|
||||
tempfile = { workspace = true }
|
||||
kigi-tty-utils = { workspace = true }
|
||||
|
||||
clap = { workspace = true, features = ["derive"] }
|
||||
|
||||
[dev-dependencies]
|
||||
reqwest = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
|
||||
[[bin]]
|
||||
name = "pty-scenario"
|
||||
path = "src/bin/pty_scenario.rs"
|
||||
|
||||
# scroll-matrix — scroll validation matrix sweep. The curated tier also runs
|
||||
# in CI (tests/scroll_matrix_curated.rs); this binary is the local full-sweep
|
||||
# and single-cell-rerun entry point.
|
||||
[[bin]]
|
||||
name = "scroll-matrix"
|
||||
path = "src/bin/scroll_matrix.rs"
|
||||
|
||||
# pty_bench scenario runner — produces a standalone binary the CI pipeline
|
||||
# pre-compiles and executes directly against a release pager artifact.
|
||||
# Lives here (not in the pager crate) so it can share the harness source
|
||||
# without any path hacks.
|
||||
[[bench]]
|
||||
name = "pty_bench"
|
||||
path = "benches/pty_bench.rs"
|
||||
harness = false
|
||||
|
||||
# paste_latency — clipboard paste latency CLI (macOS-only at runtime: drives
|
||||
# the REAL host pasteboard). Standalone so `--binary` can target old pager
|
||||
# artifacts for before/after comparisons.
|
||||
[[bench]]
|
||||
name = "paste_latency"
|
||||
path = "benches/paste_latency.rs"
|
||||
harness = false
|
||||
@@ -0,0 +1,487 @@
|
||||
//! `paste-latency` — clipboard paste latency benchmark for `kigi-tui`.
|
||||
//!
|
||||
//! Spawns the real pager binary in a PTY and measures the Ctrl+V (raw 0x16)
|
||||
//! paste path against the REAL macOS pasteboard (`pbcopy` / `osascript`):
|
||||
//!
|
||||
//! - `text` mode: `pbcopy` a sentinel, inject 0x16, measure inject → sentinel
|
||||
//! visible on screen.
|
||||
//! - `image` mode (agent surface only): put a PNG on the pasteboard, inject
|
||||
//! 0x16 followed immediately by a typed burst, and measure both
|
||||
//! responsiveness (inject → burst visible; stays flat when the clipboard
|
||||
//! read/persist is off the UI thread) and chip latency (inject →
|
||||
//! `Image #` chip visible).
|
||||
//!
|
||||
//! macOS-only at runtime (the real-clipboard Ctrl+V path only exists there);
|
||||
//! it compiles everywhere so `cargo bench --no-run` stays green on CI hosts.
|
||||
//! Only PTY input injection + screen scraping are used, so `--binary` can
|
||||
//! point at OLD pager artifacts for before/after comparisons.
|
||||
//!
|
||||
//! WARNING: overwrites the host clipboard. Prior TEXT contents are restored
|
||||
//! best-effort on exit; a prior image clipboard cannot be restored.
|
||||
//!
|
||||
//! ## Typical use
|
||||
//!
|
||||
//! ```bash
|
||||
//! cargo bench -p kigi-pager-pty-harness --bench paste_latency -- --iterations 10
|
||||
//!
|
||||
//! # Compare an old release artifact, text mode only, JSON to a file:
|
||||
//! cargo bench -p kigi-pager-pty-harness --bench paste_latency -- \
|
||||
//! --binary ~/Downloads/grok-old --mode text --json /tmp/paste-old.json
|
||||
//! ```
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::ExitCode;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use clap::Parser as ClapParser;
|
||||
use kigi_pager_pty_harness::{
|
||||
ContentController, PtyHarness,
|
||||
host_clipboard::{HostClipboardTextGuard, pbcopy, set_clipboard_png, write_fixture_png},
|
||||
pager_binary,
|
||||
results::percentile,
|
||||
};
|
||||
|
||||
/// Ctrl+V byte; a plain PTY delivers it as the Ctrl+V paste chord.
|
||||
const CTRL_V: u8 = 0x16;
|
||||
|
||||
/// Ctrl+\ as CSI-u (code 92, modifier 5) — opens the dashboard.
|
||||
const CTRL_BACKSLASH: &[u8] = b"\x1b[92;5u";
|
||||
|
||||
/// Response sentinel for the initial session turn each surface needs.
|
||||
const TURN_SENTINEL: &str = "PASTEBENCHTURNDONE";
|
||||
|
||||
#[derive(ClapParser, Debug)]
|
||||
#[command(
|
||||
name = "paste-latency",
|
||||
about = "Clipboard paste latency benchmark for kigi-tui (macOS, real pasteboard)",
|
||||
long_about = None,
|
||||
)]
|
||||
struct Cli {
|
||||
/// Path to the pager binary. Defaults to auto-resolve (PAGER_BINARY env
|
||||
/// or a locally-built debug binary). Works against old artifacts too.
|
||||
#[arg(long)]
|
||||
binary: Option<PathBuf>,
|
||||
|
||||
/// Paste payload kind to measure.
|
||||
#[arg(long, value_enum, default_value_t = Mode::All)]
|
||||
mode: Mode,
|
||||
|
||||
/// Paste surface to measure. `dashboard-dispatch` is text-only.
|
||||
#[arg(long, value_enum, default_value_t = Surface::Agent)]
|
||||
surface: Surface,
|
||||
|
||||
/// Paste iterations per (surface, mode) cell.
|
||||
#[arg(long, default_value_t = 10)]
|
||||
iterations: usize,
|
||||
|
||||
/// Terminal rows.
|
||||
#[arg(long, default_value_t = 50)]
|
||||
rows: u16,
|
||||
|
||||
/// Terminal columns.
|
||||
#[arg(long, default_value_t = 120)]
|
||||
cols: u16,
|
||||
|
||||
/// Also write the results JSON to this path (pretty JSON always goes to
|
||||
/// stdout).
|
||||
#[arg(long, value_name = "PATH")]
|
||||
json: Option<PathBuf>,
|
||||
|
||||
/// Accepted for `cargo bench` compatibility (libtest-style argument).
|
||||
/// We ignore it — this isn't a libtest harness.
|
||||
#[arg(long, hide = true)]
|
||||
#[allow(dead_code)]
|
||||
bench: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
|
||||
enum Mode {
|
||||
Text,
|
||||
Image,
|
||||
All,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
|
||||
enum Surface {
|
||||
Agent,
|
||||
DashboardDispatch,
|
||||
All,
|
||||
}
|
||||
|
||||
impl Surface {
|
||||
fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Surface::Agent => "agent",
|
||||
Surface::DashboardDispatch => "dashboard-dispatch",
|
||||
Surface::All => "all",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Aggregated latency stats for one (surface, mode) cell. For `image` the
|
||||
/// primary p50/p95/max track the chip (end-to-end attach) latency; the burst
|
||||
/// responsiveness and chip p50s are also broken out explicitly.
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
struct PasteLatencyResult {
|
||||
surface: &'static str,
|
||||
mode: &'static str,
|
||||
iterations: usize,
|
||||
p50_ms: f64,
|
||||
p95_ms: f64,
|
||||
max_ms: f64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
responsiveness_p50_ms: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
chip_p50_ms: Option<f64>,
|
||||
}
|
||||
|
||||
#[tokio::main(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn main() -> ExitCode {
|
||||
tracing_subscriber::fmt()
|
||||
.with_writer(std::io::stderr)
|
||||
.init();
|
||||
|
||||
match run().await {
|
||||
Ok(code) => code,
|
||||
Err(e) => {
|
||||
eprintln!("paste-latency failed: {e:#}");
|
||||
ExitCode::from(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn run() -> Result<ExitCode> {
|
||||
let cli = Cli::parse();
|
||||
|
||||
if !cfg!(target_os = "macos") {
|
||||
bail!(
|
||||
"paste-latency drives the REAL macOS pasteboard (pbcopy/osascript) through the \
|
||||
pager's Ctrl+V path, which only exists on macOS — run it on a Mac"
|
||||
);
|
||||
}
|
||||
|
||||
let binary = match cli.binary {
|
||||
Some(b) => b,
|
||||
None => pager_binary().context("resolve pager binary")?,
|
||||
};
|
||||
|
||||
let surfaces: Vec<Surface> = match cli.surface {
|
||||
Surface::All => vec![Surface::Agent, Surface::DashboardDispatch],
|
||||
s => vec![s],
|
||||
};
|
||||
let modes: Vec<Mode> = match cli.mode {
|
||||
Mode::All => vec![Mode::Text, Mode::Image],
|
||||
m => vec![m],
|
||||
};
|
||||
|
||||
tracing::info!(
|
||||
binary = %binary.display(),
|
||||
rows = cli.rows,
|
||||
cols = cli.cols,
|
||||
iterations = cli.iterations,
|
||||
"starting paste-latency run"
|
||||
);
|
||||
|
||||
// Restore the prior TEXT clipboard on exit, best effort (images can't be).
|
||||
let _restore = HostClipboardTextGuard::save();
|
||||
|
||||
let mut results: Vec<PasteLatencyResult> = Vec::new();
|
||||
for &surface in &surfaces {
|
||||
for &mode in &modes {
|
||||
if surface == Surface::DashboardDispatch && mode == Mode::Image {
|
||||
// Image pastes are an agent-prompt workflow; the dashboard cell
|
||||
// only tracks text latency.
|
||||
tracing::info!("skipping dashboard-dispatch image cell (text-only surface)");
|
||||
continue;
|
||||
}
|
||||
let res = bench_cell(&binary, cli.rows, cli.cols, surface, mode, cli.iterations).await;
|
||||
match res {
|
||||
Ok(r) => results.push(r),
|
||||
// Dashboard driving is best-effort: report and keep the agent numbers.
|
||||
Err(e) if surface == Surface::DashboardDispatch => {
|
||||
tracing::warn!(error = %e, "dashboard-dispatch cell failed; dropping surface");
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// An all-skipped/dropped run measured nothing; fail loudly instead of
|
||||
// handing downstream tooling an empty [] with exit 0.
|
||||
if results.is_empty() {
|
||||
eprintln!(
|
||||
"paste-latency: no cells ran — every requested surface/mode combination was skipped or dropped"
|
||||
);
|
||||
return Ok(ExitCode::from(1));
|
||||
}
|
||||
|
||||
let json = serde_json::to_string_pretty(&results).context("serialize results")?;
|
||||
println!("{json}");
|
||||
if let Some(path) = cli.json {
|
||||
std::fs::write(&path, &json)
|
||||
.with_context(|| format!("write results JSON to {}", path.display()))?;
|
||||
eprintln!("wrote results to {}", path.display());
|
||||
}
|
||||
|
||||
Ok(ExitCode::SUCCESS)
|
||||
}
|
||||
|
||||
/// Run one (surface, mode) cell: one spawned pager + session reused across all
|
||||
/// iterations, cleared between pastes (with a respawn fallback if a clear
|
||||
/// fails to take).
|
||||
async fn bench_cell(
|
||||
binary: &Path,
|
||||
rows: u16,
|
||||
cols: u16,
|
||||
surface: Surface,
|
||||
mode: Mode,
|
||||
iterations: usize,
|
||||
) -> Result<PasteLatencyResult> {
|
||||
let mode_str = match mode {
|
||||
Mode::Text => "text",
|
||||
Mode::Image => "image",
|
||||
Mode::All => unreachable!("cells are per concrete mode"),
|
||||
};
|
||||
tracing::info!(surface = surface.as_str(), mode = mode_str, "running cell");
|
||||
|
||||
let content = ContentController::start()
|
||||
.await
|
||||
.context("start ContentController")?;
|
||||
content.set_response(format!("{TURN_SENTINEL} initial bench turn."));
|
||||
let mut harness = spawn_ready(binary, rows, cols, &content, surface)?;
|
||||
|
||||
// Image mode keeps one PNG on the pasteboard for the whole cell (the
|
||||
// pager re-reads the unchanged clipboard on every Ctrl+V).
|
||||
let tmp = tempfile::tempdir().context("tempdir for the clipboard PNG")?;
|
||||
if mode == Mode::Image {
|
||||
let png = write_fixture_png(tmp.path())?;
|
||||
set_clipboard_png(&png)?;
|
||||
}
|
||||
|
||||
let nonce = std::process::id();
|
||||
let mut primary_ms: Vec<f64> = Vec::with_capacity(iterations);
|
||||
let mut responsiveness_ms: Vec<f64> = Vec::with_capacity(iterations);
|
||||
|
||||
for i in 0..iterations {
|
||||
match mode {
|
||||
Mode::Text => {
|
||||
let sentinel = format!("PASTELATENCY{i} N{nonce}");
|
||||
pbcopy(&sentinel)?;
|
||||
let start = Instant::now();
|
||||
harness.inject_keys(&[CTRL_V]).context("inject Ctrl+V")?;
|
||||
wait_visible(&mut harness, &sentinel, Duration::from_secs(10))?;
|
||||
let ms = start.elapsed().as_secs_f64() * 1000.0;
|
||||
eprintln!(
|
||||
" [{}/{mode_str}] iter {i}: paste {ms:.1} ms",
|
||||
surface.as_str()
|
||||
);
|
||||
primary_ms.push(ms);
|
||||
clear_input_or_respawn(
|
||||
binary,
|
||||
rows,
|
||||
cols,
|
||||
&content,
|
||||
surface,
|
||||
&mut harness,
|
||||
&[&sentinel],
|
||||
)?;
|
||||
}
|
||||
Mode::Image => {
|
||||
let burst = format!("R{i}Z");
|
||||
let mut keys = vec![CTRL_V];
|
||||
keys.extend_from_slice(burst.as_bytes());
|
||||
let start = Instant::now();
|
||||
harness
|
||||
.inject_keys(&keys)
|
||||
.context("inject Ctrl+V + typed burst")?;
|
||||
wait_visible(&mut harness, &burst, Duration::from_secs(10))?;
|
||||
let resp = start.elapsed().as_secs_f64() * 1000.0;
|
||||
wait_visible(&mut harness, "Image #", Duration::from_secs(30))?;
|
||||
let chip = start.elapsed().as_secs_f64() * 1000.0;
|
||||
eprintln!(
|
||||
" [{}/{mode_str}] iter {i}: responsiveness {resp:.1} ms, chip {chip:.1} ms",
|
||||
surface.as_str()
|
||||
);
|
||||
responsiveness_ms.push(resp);
|
||||
primary_ms.push(chip);
|
||||
clear_input_or_respawn(
|
||||
binary,
|
||||
rows,
|
||||
cols,
|
||||
&content,
|
||||
surface,
|
||||
&mut harness,
|
||||
&["Image #", &burst],
|
||||
)?;
|
||||
}
|
||||
Mode::All => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
let _ = harness.quit();
|
||||
|
||||
let (p50, p95, max) = stats(&mut primary_ms);
|
||||
let responsiveness_p50 =
|
||||
(!responsiveness_ms.is_empty()).then(|| stats(&mut responsiveness_ms).0);
|
||||
let result = PasteLatencyResult {
|
||||
surface: surface.as_str(),
|
||||
mode: mode_str,
|
||||
iterations,
|
||||
p50_ms: p50,
|
||||
p95_ms: p95,
|
||||
max_ms: max,
|
||||
responsiveness_p50_ms: responsiveness_p50,
|
||||
chip_p50_ms: (mode == Mode::Image).then_some(p50),
|
||||
};
|
||||
eprintln!(
|
||||
"cell {}/{}: p50 {:.1} ms, p95 {:.1} ms, max {:.1} ms{}",
|
||||
result.surface,
|
||||
result.mode,
|
||||
result.p50_ms,
|
||||
result.p95_ms,
|
||||
result.max_ms,
|
||||
result
|
||||
.responsiveness_p50_ms
|
||||
.map(|r| format!(", responsiveness p50 {r:.1} ms"))
|
||||
.unwrap_or_default()
|
||||
);
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Spawn the pager and drive it to a ready paste surface: welcome → one
|
||||
/// completed turn (idle session prompt); for the dashboard, Ctrl+\ on top.
|
||||
fn spawn_ready(
|
||||
binary: &Path,
|
||||
rows: u16,
|
||||
cols: u16,
|
||||
content: &ContentController,
|
||||
surface: Surface,
|
||||
) -> Result<PtyHarness> {
|
||||
let mut harness = PtyHarness::spawn_with_content(binary, rows, cols, content, &[])
|
||||
.context("spawn pager PTY harness")?;
|
||||
harness
|
||||
.wait_for_text("Quit", Duration::from_secs(20))
|
||||
.context("welcome screen")?;
|
||||
harness
|
||||
.inject_keys(b"go\r")
|
||||
.context("submit initial turn")?;
|
||||
harness
|
||||
.wait_for_text(TURN_SENTINEL, Duration::from_secs(30))
|
||||
.context("initial turn response")?;
|
||||
// Let post-turn animation settle so paste frames aren't queued behind it.
|
||||
harness.update(Duration::from_secs(1));
|
||||
|
||||
if surface == Surface::DashboardDispatch {
|
||||
harness
|
||||
.inject_keys(CTRL_BACKSLASH)
|
||||
.context("Ctrl+\\ open dashboard")?;
|
||||
harness
|
||||
.wait_for_text("+ New Agent", Duration::from_secs(10))
|
||||
.context("dashboard list")?;
|
||||
// Opening from a session lands with the LIST focused (footer offers
|
||||
// "Tab:input"); Tab moves focus to the dispatch input so the clear
|
||||
// keys between iterations reach it. Ctrl+V itself is focus-agnostic.
|
||||
harness.update(Duration::from_millis(300));
|
||||
if harness.contains_text("Tab:input") {
|
||||
harness
|
||||
.inject_keys(b"\t")
|
||||
.context("Tab focus dispatch input")?;
|
||||
if !wait_absent(&mut harness, &["Tab:input"], Duration::from_secs(3)) {
|
||||
bail!(
|
||||
"dashboard dispatch input did not take focus (footer still offers Tab:input)\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(harness)
|
||||
}
|
||||
|
||||
/// Clear the pasted content between iterations; if the input refuses to clear
|
||||
/// (needles still on screen), fall back to a full respawn so the next
|
||||
/// iteration starts clean.
|
||||
fn clear_input_or_respawn(
|
||||
binary: &Path,
|
||||
rows: u16,
|
||||
cols: u16,
|
||||
content: &ContentController,
|
||||
surface: Surface,
|
||||
harness: &mut PtyHarness,
|
||||
stale: &[&str],
|
||||
) -> Result<()> {
|
||||
// Ctrl+U kills to line start, removing pasted text and chip elements.
|
||||
harness.inject_keys(b"\x15").context("Ctrl+U clear")?;
|
||||
if wait_absent(harness, stale, Duration::from_secs(2)) {
|
||||
return Ok(());
|
||||
}
|
||||
// Backspace spam (the dashboard consumes Ctrl+U as half-page scroll
|
||||
// before its dispatch input sees it); chips delete atomically, so 64
|
||||
// presses cover any bench payload.
|
||||
harness
|
||||
.inject_keys(&[0x7f; 64])
|
||||
.context("Backspace clear")?;
|
||||
if wait_absent(harness, stale, Duration::from_secs(2)) {
|
||||
return Ok(());
|
||||
}
|
||||
if surface == Surface::Agent {
|
||||
// Drafted-prompt Esc arms press-again-to-clear; the second press wipes
|
||||
// the whole input. Only safe while a draft remains (an empty-prompt
|
||||
// Esc-Esc opens the rewind picker), which the absent-checks ruled out.
|
||||
harness.inject_keys(b"\x1b").context("Esc arm clear")?;
|
||||
harness.update(Duration::from_millis(250));
|
||||
harness.inject_keys(b"\x1b").context("Esc confirm clear")?;
|
||||
if wait_absent(harness, stale, Duration::from_secs(2)) {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
tracing::warn!(
|
||||
surface = surface.as_str(),
|
||||
"input did not clear; respawning harness"
|
||||
);
|
||||
let _ = harness.quit();
|
||||
*harness = spawn_ready(binary, rows, cols, content, surface)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Tight-poll (5 ms slices, vs `wait_for_text`'s 50 ms) until `needle` is on
|
||||
/// screen, for low measurement quantization.
|
||||
fn wait_visible(harness: &mut PtyHarness, needle: &str, timeout: Duration) -> Result<()> {
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
if harness.contains_text(needle) {
|
||||
return Ok(());
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
bail!(
|
||||
"timed out after {timeout:?} waiting for {needle:?}\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
}
|
||||
harness.update(Duration::from_millis(5));
|
||||
}
|
||||
}
|
||||
|
||||
/// Pump output until every needle is gone or `timeout` elapses.
|
||||
fn wait_absent(harness: &mut PtyHarness, needles: &[&str], timeout: Duration) -> bool {
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
if needles.iter().all(|n| !harness.contains_text(n)) {
|
||||
return true;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return false;
|
||||
}
|
||||
harness.update(Duration::from_millis(50));
|
||||
}
|
||||
}
|
||||
|
||||
/// (p50, p95, max) over `samples`, reusing the shared percentile helper.
|
||||
fn stats(samples: &mut [f64]) -> (f64, f64, f64) {
|
||||
samples.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
||||
(
|
||||
percentile(samples, 50.0),
|
||||
percentile(samples, 95.0),
|
||||
samples.last().copied().unwrap_or(0.0),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
# PTY benchmark baselines
|
||||
|
||||
Baselines are per-platform (macOS arm64 has very different timing from an
|
||||
Linux arm64 CI runner) and per-scenario. CI compares the current run
|
||||
against the matching platform file and fails if any scenario's p99 frame
|
||||
time grows by more than 15% (default; `--threshold` overrides).
|
||||
|
||||
File naming: `<platform>.json` where `<platform>` matches the CI artifact
|
||||
arch name — `linux-x86_64`, `linux-aarch64`, `macos-aarch64`.
|
||||
|
||||
## Producing a baseline
|
||||
|
||||
Run the full bench suite on a quiet machine:
|
||||
|
||||
```bash
|
||||
cargo run -p kigi-tui --release --bin pty-bench -- \
|
||||
--all \
|
||||
--write-baseline crates/codegen/kigi-pager-pty-harness/benches/pty_baselines/<platform>.json
|
||||
```
|
||||
|
||||
## Overwriting after an intentional perf change
|
||||
|
||||
A PR that intentionally shifts frame timing (either direction) must update
|
||||
the affected baselines. Include the `pty-bench` output from a clean run in
|
||||
the PR body so reviewers can sanity-check the new numbers.
|
||||
|
||||
## First run
|
||||
|
||||
Platform files are seeded on first CI run (see `pager-bench` job). Until
|
||||
then, `--baseline <missing-file>` will fail loudly with a clear error.
|
||||
@@ -0,0 +1,200 @@
|
||||
//! `pty-bench` — PTY benchmark CLI for `kigi-tui`.
|
||||
//!
|
||||
//! Spawns the real pager binary in a PTY, dispatches named scenarios, and
|
||||
//! emits aggregated results as JSON. Supports baseline comparison for CI
|
||||
//! regression detection.
|
||||
//!
|
||||
//! ## Typical use
|
||||
//!
|
||||
//! Run a single scenario locally:
|
||||
//! ```bash
|
||||
//! cargo bench -p kigi-pager-pty-harness \
|
||||
//! --bench pty_bench -- --scenario scroll-stress
|
||||
//! ```
|
||||
//!
|
||||
//! Run every scenario and write a new baseline:
|
||||
//! ```bash
|
||||
//! cargo bench -p kigi-pager-pty-harness \
|
||||
//! --bench pty_bench -- --all \
|
||||
//! --write-baseline benches/pty_baselines/local.json
|
||||
//! ```
|
||||
//!
|
||||
//! Run every scenario in CI and fail on >15% p99 regression:
|
||||
//! ```bash
|
||||
//! PAGER_BINARY=./artifacts/grok-${VERSION}-linux-x86_64 \
|
||||
//! cargo bench -p kigi-pager-pty-harness \
|
||||
//! --bench pty_bench -- --all \
|
||||
//! --baseline benches/pty_baselines/linux-x86_64.json
|
||||
//! ```
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::process::ExitCode;
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use clap::Parser as ClapParser;
|
||||
use kigi_pager_pty_harness::{
|
||||
BenchResults, ContentController, PtyHarness, Scenario, compare_baseline, pager_binary,
|
||||
results::{DEFAULT_REGRESSION_THRESHOLD, load_baseline, write_baseline},
|
||||
};
|
||||
|
||||
#[derive(ClapParser, Debug)]
|
||||
#[command(
|
||||
name = "pty-bench",
|
||||
about = "PTY benchmark harness for kigi-tui",
|
||||
long_about = None,
|
||||
)]
|
||||
struct Cli {
|
||||
/// Run a single scenario by name. Mutually exclusive with --all.
|
||||
#[arg(long, value_enum, conflicts_with = "all")]
|
||||
scenario: Option<Scenario>,
|
||||
|
||||
/// Run every scenario.
|
||||
#[arg(long)]
|
||||
all: bool,
|
||||
|
||||
/// Path to the pager binary. Defaults to auto-resolve (PAGER_BINARY env
|
||||
/// or a locally-built debug binary).
|
||||
#[arg(long)]
|
||||
binary: Option<PathBuf>,
|
||||
|
||||
/// Terminal rows.
|
||||
#[arg(long, default_value_t = 50)]
|
||||
rows: u16,
|
||||
|
||||
/// Terminal columns.
|
||||
#[arg(long, default_value_t = 120)]
|
||||
cols: u16,
|
||||
|
||||
/// Compare results against a baseline file and exit non-zero on
|
||||
/// regression (>15% p99 delta by default).
|
||||
#[arg(long, value_name = "PATH")]
|
||||
baseline: Option<PathBuf>,
|
||||
|
||||
/// Save the current run as a new baseline.
|
||||
#[arg(long, value_name = "PATH", conflicts_with = "baseline")]
|
||||
write_baseline: Option<PathBuf>,
|
||||
|
||||
/// Regression threshold as a fraction of baseline p99 (0.15 = 15%).
|
||||
#[arg(long, default_value_t = DEFAULT_REGRESSION_THRESHOLD)]
|
||||
threshold: f64,
|
||||
|
||||
/// Accepted for `cargo bench` compatibility (libtest-style argument).
|
||||
/// We ignore it — this isn't a libtest harness.
|
||||
#[arg(long, hide = true)]
|
||||
#[allow(dead_code)]
|
||||
bench: bool,
|
||||
}
|
||||
|
||||
#[tokio::main(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn main() -> ExitCode {
|
||||
tracing_subscriber::fmt()
|
||||
.with_writer(std::io::stderr)
|
||||
.init();
|
||||
|
||||
match run().await {
|
||||
Ok(code) => code,
|
||||
Err(e) => {
|
||||
eprintln!("pty-bench failed: {e:#}");
|
||||
ExitCode::from(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn run() -> Result<ExitCode> {
|
||||
let cli = Cli::parse();
|
||||
|
||||
let binary = match cli.binary {
|
||||
Some(b) => b,
|
||||
None => pager_binary().context("resolve pager binary")?,
|
||||
};
|
||||
let scenarios: Vec<Scenario> = if cli.all {
|
||||
Scenario::ALL.to_vec()
|
||||
} else if let Some(s) = cli.scenario {
|
||||
vec![s]
|
||||
} else {
|
||||
bail!("specify --scenario <name> or --all");
|
||||
};
|
||||
|
||||
tracing::info!(
|
||||
binary = %binary.display(),
|
||||
rows = cli.rows,
|
||||
cols = cli.cols,
|
||||
count = scenarios.len(),
|
||||
"starting pty-bench run"
|
||||
);
|
||||
|
||||
let mut results: Vec<BenchResults> = Vec::with_capacity(scenarios.len());
|
||||
for scenario in scenarios {
|
||||
tracing::info!(scenario = scenario.as_str(), "running scenario");
|
||||
let content = ContentController::start()
|
||||
.await
|
||||
.context("start ContentController")?;
|
||||
let mut harness =
|
||||
PtyHarness::spawn_with_content(&binary, cli.rows, cli.cols, &content, &[])
|
||||
.context("spawn pager PTY harness")?;
|
||||
|
||||
let res = scenario.run(&mut harness, &content).await;
|
||||
|
||||
// Best-effort cleanup regardless of scenario outcome.
|
||||
let _ = harness.quit();
|
||||
|
||||
match res {
|
||||
Ok(r) => {
|
||||
tracing::info!(
|
||||
scenario = %r.scenario,
|
||||
frames = r.total_frames,
|
||||
p50_ms = r.p50_ms,
|
||||
p99_ms = r.p99_ms,
|
||||
"scenario complete"
|
||||
);
|
||||
results.push(r);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(scenario = scenario.as_str(), error = %e, "scenario failed");
|
||||
results.push(BenchResults::from_timings(
|
||||
scenario.as_str(),
|
||||
&[],
|
||||
std::time::Duration::ZERO,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Emit JSON to stdout for downstream consumption.
|
||||
let json = serde_json::to_string_pretty(&results).context("serialize results")?;
|
||||
println!("{json}");
|
||||
|
||||
if let Some(path) = cli.write_baseline {
|
||||
write_baseline(&path, &results)?;
|
||||
eprintln!("wrote baseline to {}", path.display());
|
||||
}
|
||||
|
||||
if let Some(path) = cli.baseline {
|
||||
let baseline = load_baseline(&path)?;
|
||||
let regressions = compare_baseline(&results, &baseline, cli.threshold);
|
||||
if regressions.is_empty() {
|
||||
eprintln!(
|
||||
"OK: no scenarios regressed beyond {:.0}% of baseline p99",
|
||||
cli.threshold * 100.0
|
||||
);
|
||||
} else {
|
||||
eprintln!(
|
||||
"REGRESSION: {} scenario(s) exceeded {:.0}% p99 threshold:",
|
||||
regressions.len(),
|
||||
cli.threshold * 100.0
|
||||
);
|
||||
for r in ®ressions {
|
||||
eprintln!(
|
||||
" {}: {:.2}ms -> {:.2}ms ({:+.1}%)",
|
||||
r.scenario,
|
||||
r.baseline_p99_ms,
|
||||
r.current_p99_ms,
|
||||
r.pct_delta * 100.0
|
||||
);
|
||||
}
|
||||
return Ok(ExitCode::from(1));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(ExitCode::SUCCESS)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
//! `pty-scenario` — scripted TUI regression runner for kigi-tui.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::process::ExitCode;
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use clap::Parser as ClapParser;
|
||||
use kigi_pager_pty_harness::{
|
||||
ScriptedRunConfig, ScriptedRunStatus, ScriptedScenario, ScriptedScenarioRunner, pager_binary,
|
||||
};
|
||||
|
||||
#[derive(ClapParser, Debug)]
|
||||
#[command(
|
||||
name = "pty-scenario",
|
||||
about = "Run declarative TUI regression scenarios against kigi-tui",
|
||||
long_about = None,
|
||||
)]
|
||||
struct Cli {
|
||||
/// Scenario file to run. Supports JSON, YAML, and YML.
|
||||
#[arg(long, value_name = "PATH")]
|
||||
scenario: PathBuf,
|
||||
|
||||
/// Pager binary. Defaults to PAGER_BINARY, CARGO_BIN_EXE_kigi-tui,
|
||||
/// or a locally-built debug binary.
|
||||
#[arg(long, value_name = "PATH")]
|
||||
binary: Option<PathBuf>,
|
||||
|
||||
/// Directory for report.json, bugs.md, text/html/svg/styled-json captures.
|
||||
#[arg(long, value_name = "DIR", default_value = "target/pty-scenarios")]
|
||||
artifacts: PathBuf,
|
||||
}
|
||||
|
||||
#[tokio::main(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn main() -> ExitCode {
|
||||
match run().await {
|
||||
Ok(status) => status,
|
||||
Err(error) => {
|
||||
eprintln!("pty-scenario failed: {error:#}");
|
||||
ExitCode::from(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn run() -> Result<ExitCode> {
|
||||
let cli = Cli::parse();
|
||||
let scenario = ScriptedScenario::from_file(&cli.scenario)
|
||||
.with_context(|| format!("load scenario {}", cli.scenario.display()))?;
|
||||
let binary = match cli.binary {
|
||||
Some(path) => path,
|
||||
None => pager_binary().context("resolve pager binary")?,
|
||||
};
|
||||
if !binary.exists() {
|
||||
bail!("pager binary does not exist: {}", binary.display());
|
||||
}
|
||||
|
||||
let runner = ScriptedScenarioRunner::new(ScriptedRunConfig::new(binary, cli.artifacts));
|
||||
let report = runner.run(&scenario).await?;
|
||||
let json = serde_json::to_string_pretty(&report).context("serialize final report")?;
|
||||
println!("{json}");
|
||||
|
||||
match report.status {
|
||||
ScriptedRunStatus::Passed | ScriptedRunStatus::Skipped => Ok(ExitCode::SUCCESS),
|
||||
ScriptedRunStatus::Failed | ScriptedRunStatus::Running => Ok(ExitCode::from(1)),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
//! `scroll-matrix` — scroll validation matrix sweep for kigi-tui.
|
||||
//!
|
||||
//! Runs matrix cells (`scroll_matrix::CELLS`) against a real pager binary in
|
||||
//! a PTY, prints the per-cell verdict table, writes `report.json` into the
|
||||
//! artifacts dir (next to each cell's recorder capture), and exits nonzero
|
||||
//! iff any cell failed or an xfail cell passed. The curated tier also runs
|
||||
//! in CI as `tests/scroll_matrix_curated.rs`; this binary is the local
|
||||
//! entry point for the full sweep and for one-off cell reruns (`--filter`).
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::process::ExitCode;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use clap::{Parser as ClapParser, ValueEnum};
|
||||
use kigi_pager_pty_harness::pager_binary;
|
||||
use kigi_pager_pty_harness::scroll_matrix::{
|
||||
CELLS, CellReport, MatrixCell, Tier, exit_code, run_cell, summary_table, write_report_json,
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
|
||||
enum TierArg {
|
||||
/// The CI subset.
|
||||
Curated,
|
||||
/// Every cell (curated + full-tier rows).
|
||||
Full,
|
||||
}
|
||||
|
||||
#[derive(ClapParser, Debug)]
|
||||
#[command(
|
||||
name = "scroll-matrix",
|
||||
about = "Run the scroll validation matrix against kigi-tui",
|
||||
long_about = None,
|
||||
)]
|
||||
struct Cli {
|
||||
/// Cell tier to run.
|
||||
#[arg(long, value_enum, default_value_t = TierArg::Curated)]
|
||||
tier: TierArg,
|
||||
|
||||
/// Only run cells whose id contains this substring.
|
||||
#[arg(long, value_name = "SUBSTR")]
|
||||
filter: Option<String>,
|
||||
|
||||
/// Concurrent cells. Default 1 because gestures are host-paced: parallel
|
||||
/// cells contend for CPU and stretch the inter-report sleeps, which can
|
||||
/// flip Auto-mode classifications. The invariant suite is stall-safe (it
|
||||
/// judges timing from the recorder's clock), so raising this stays sound
|
||||
/// — it just makes captures less representative of real gesture timing.
|
||||
#[arg(long, value_name = "N", default_value_t = 1)]
|
||||
jobs: usize,
|
||||
|
||||
/// Directory for report.json and the per-cell recorder captures.
|
||||
#[arg(long, value_name = "DIR", default_value = "target/scroll-matrix")]
|
||||
artifacts: PathBuf,
|
||||
|
||||
/// Pager binary. Defaults to PAGER_BINARY, CARGO_BIN_EXE_kigi-tui,
|
||||
/// or a locally-built debug binary.
|
||||
#[arg(long, value_name = "PATH")]
|
||||
binary: Option<PathBuf>,
|
||||
}
|
||||
|
||||
// Multi-thread runtime required: run_cell drives its blocking cell body via
|
||||
// Handle::block_on on a spawn_blocking thread (see scroll_matrix::runner).
|
||||
#[tokio::main(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn main() -> ExitCode {
|
||||
match run().await {
|
||||
Ok(code) => code,
|
||||
Err(error) => {
|
||||
eprintln!("scroll-matrix failed: {error:#}");
|
||||
ExitCode::from(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn run() -> Result<ExitCode> {
|
||||
let cli = Cli::parse();
|
||||
let binary = match cli.binary {
|
||||
Some(path) => path,
|
||||
None => pager_binary().context("resolve pager binary")?,
|
||||
};
|
||||
if !binary.exists() {
|
||||
bail!("pager binary does not exist: {}", binary.display());
|
||||
}
|
||||
|
||||
let cells: Vec<&'static MatrixCell> = CELLS
|
||||
.iter()
|
||||
.filter(|cell| cli.tier == TierArg::Full || cell.tier == Tier::Curated)
|
||||
.filter(|cell| {
|
||||
cli.filter
|
||||
.as_deref()
|
||||
.is_none_or(|needle| cell.id.contains(needle))
|
||||
})
|
||||
.collect();
|
||||
if cells.is_empty() {
|
||||
bail!(
|
||||
"no cells match --tier {:?} --filter {:?}",
|
||||
cli.tier,
|
||||
cli.filter
|
||||
);
|
||||
}
|
||||
|
||||
// The pager child resolves KIGI_SCROLL_LOG against ITS cwd (the
|
||||
// harness's temp workspace), so a relative artifacts dir — including
|
||||
// the default — would scatter captures there and starve the finalize
|
||||
// wait. Absolutize against the invoking cwd.
|
||||
let artifacts = std::path::absolute(&cli.artifacts)
|
||||
.with_context(|| format!("absolutize artifacts dir {}", cli.artifacts.display()))?;
|
||||
|
||||
let reports = run_cells(&cells, cli.jobs.max(1), &binary, &artifacts).await;
|
||||
print!("{}", summary_table(&reports));
|
||||
let report_path = write_report_json(&reports, &artifacts)?;
|
||||
eprintln!("report: {}", report_path.display());
|
||||
Ok(ExitCode::from(exit_code(&reports)))
|
||||
}
|
||||
|
||||
/// Run every cell, preserving table order. `jobs == 1` runs inline (the
|
||||
/// representative-timing default); higher values fan out over a semaphore.
|
||||
async fn run_cells(
|
||||
cells: &[&'static MatrixCell],
|
||||
jobs: usize,
|
||||
binary: &std::path::Path,
|
||||
artifacts: &std::path::Path,
|
||||
) -> Vec<CellReport> {
|
||||
if jobs == 1 {
|
||||
let mut reports = Vec::with_capacity(cells.len());
|
||||
for (i, cell) in cells.iter().enumerate() {
|
||||
eprintln!("[{}/{}] {}", i + 1, cells.len(), cell.id);
|
||||
reports.push(run_cell(cell, binary, artifacts).await);
|
||||
}
|
||||
return reports;
|
||||
}
|
||||
|
||||
let semaphore = Arc::new(tokio::sync::Semaphore::new(jobs));
|
||||
let mut set = tokio::task::JoinSet::new();
|
||||
for (index, &cell) in cells.iter().enumerate() {
|
||||
let semaphore = semaphore.clone();
|
||||
let (binary, artifacts) = (binary.to_path_buf(), artifacts.to_path_buf());
|
||||
set.spawn(async move {
|
||||
let _permit = semaphore.acquire_owned().await.expect("semaphore open");
|
||||
eprintln!("running {}", cell.id);
|
||||
(index, run_cell(cell, &binary, &artifacts).await)
|
||||
});
|
||||
}
|
||||
let mut indexed = Vec::with_capacity(cells.len());
|
||||
while let Some(joined) = set.join_next().await {
|
||||
// run_cell converts cell panics into Fail reports itself.
|
||||
indexed.push(joined.expect("cell task join"));
|
||||
}
|
||||
indexed.sort_by_key(|(index, _)| *index);
|
||||
indexed.into_iter().map(|(_, report)| report).collect()
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
//! Layer 3: Content controller.
|
||||
//!
|
||||
//! An idle pager only renders a splash screen — not useful for scroll,
|
||||
//! stream, or resize scenarios. [`ContentController`] wraps the shared
|
||||
//! [`MockInferenceServer`] from `kigi-test-support` and provides the
|
||||
//! env vars that point the bundled shell agent at it, so the pager ends
|
||||
//! up rendering real agent output.
|
||||
//!
|
||||
//! The caller controls the response text via [`ContentController::set_response`].
|
||||
//! The mock server streams the set response to every inference request.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use kigi_test_support::MockInferenceServer;
|
||||
|
||||
pub use kigi_test_support::mock_server::LogEntry;
|
||||
pub use kigi_test_support::mock_server::MockModelEntry as MockModel;
|
||||
pub use kigi_test_support::mock_server::StorageUpload;
|
||||
// SSE event builders for `enqueue_response` scripts (reasoning turns etc.).
|
||||
pub use kigi_test_support::sse;
|
||||
pub use kigi_test_support::{ScriptedResponse, SseEvent};
|
||||
|
||||
/// Drives content into the pager by serving a mock inference endpoint that
|
||||
/// the bundled shell agent hits for `/v1/chat/completions` and `/v1/responses`.
|
||||
///
|
||||
/// Thin wrapper over the shared [`MockInferenceServer`]: adds the isolated
|
||||
/// `$HOME` sandbox and pager env plumbing, and applies the harness defaults
|
||||
/// the pager depends on (always-200 `/v1/settings`, fixed default response).
|
||||
///
|
||||
/// Shuts the server down on drop (the inner server's `Drop`).
|
||||
pub struct ContentController {
|
||||
server: MockInferenceServer,
|
||||
home: tempfile::TempDir,
|
||||
}
|
||||
|
||||
impl ContentController {
|
||||
/// Start the mock inference server on a random local port.
|
||||
///
|
||||
/// Must be called from within a tokio runtime.
|
||||
pub async fn start() -> Result<Self> {
|
||||
Self::start_with_models(vec![MockModel::new("test-model")]).await
|
||||
}
|
||||
|
||||
/// Start the mock server with a custom set of models returned by
|
||||
/// `GET /v1/models`. Use [`MockModel::with_agent_type`] to configure
|
||||
/// models with different harness types for agent-type-mismatch tests.
|
||||
pub async fn start_with_models(models: Vec<MockModel>) -> Result<Self> {
|
||||
let server = MockInferenceServer::start_with_models(models)
|
||||
.await
|
||||
.context("start mock inference server")?;
|
||||
// Pre-delegation parity, both load-bearing for PTY tests: settings
|
||||
// must be 200 `{"allow_access": true}` (the shared 404-until-set
|
||||
// default strands the pager on the upsell screen), and the response
|
||||
// mode must be a fixed text (the shared default is echo).
|
||||
server.preset_allow_access();
|
||||
server.set_response(default_response_text());
|
||||
|
||||
let home = tempfile::tempdir().context("create temp HOME")?;
|
||||
|
||||
Ok(Self { server, home })
|
||||
}
|
||||
|
||||
/// Base URL of the mock server, e.g. `http://127.0.0.1:41823/v1`.
|
||||
pub fn url(&self) -> String {
|
||||
self.server.url()
|
||||
}
|
||||
|
||||
/// Isolated `$HOME` directory that the pager should use (keeps its ~/.kigi
|
||||
/// cache/state out of the real home during tests).
|
||||
pub fn home(&self) -> &Path {
|
||||
self.home.path()
|
||||
}
|
||||
|
||||
/// Env vars to pass to the pager process so it hits the mock server
|
||||
/// with telemetry / feedback disabled.
|
||||
///
|
||||
/// Mirrors `kigi_test_support::env::test_env_cmd_tokio`.
|
||||
pub fn env_for_pager(&self) -> Vec<(String, String)> {
|
||||
let home = self.home.path().to_string_lossy().into_owned();
|
||||
let kigi_home = self
|
||||
.home
|
||||
.path()
|
||||
.join(".kigi")
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
vec![
|
||||
("HOME".into(), home),
|
||||
// Explicit KIGI_SHARE_DIR prevents leaking the real user's
|
||||
// config.toml when $HOME alone isn't sufficient (e.g. if
|
||||
// KIGI_SHARE_DIR is set in the test runner's env).
|
||||
("KIGI_SHARE_DIR".into(), kigi_home),
|
||||
("KIGI_CLI_CHAT_PROXY_BASE_URL".into(), self.url()),
|
||||
("KIGI_XAI_API_BASE_URL".into(), self.url()),
|
||||
("XAI_API_KEY".into(), "test-key-for-ci".into()),
|
||||
("KIGI_TELEMETRY_ENABLED".into(), "false".into()),
|
||||
("KIGI_FEEDBACK_ENABLED".into(), "false".into()),
|
||||
("KIGI_TRACE_UPLOAD".into(), "false".into()),
|
||||
// Next-prompt autocomplete fires an extra background model call
|
||||
// at every turn end (default ON). Off by default in PTY tests so
|
||||
// the mock's fixed response can't leak in as ghost text and
|
||||
// scripted per-path FIFOs aren't consumed by it. Tests exercising
|
||||
// the feature re-enable it via extra env.
|
||||
("KIGI_PROMPT_SUGGESTIONS".into(), "false".into()),
|
||||
// No inference retries in tests. The mock always answers 200, so a
|
||||
// retry only ever fires when a turn is deliberately stalled
|
||||
// (`hold_agent_completions` / a long `chunk_delay`). On a slow
|
||||
// runner that stall can exceed the client's first-token budget and
|
||||
// retry the request — and because the mock serves `set_agent_turns`
|
||||
// by popping one response per REQUEST, a retry consumes the next
|
||||
// turn's slot, misaligning every following turn (the promoted queue
|
||||
// prompt then hangs waiting for a response that was already popped).
|
||||
// Pinning retries to 0 keeps one request == one turn.
|
||||
("KIGI_MAX_RETRIES".into(), "0".into()),
|
||||
]
|
||||
}
|
||||
|
||||
/// Replace the mocked assistant response. All subsequent chat completion
|
||||
/// requests will stream this text word-by-word.
|
||||
pub fn set_response(&self, text: impl Into<String>) {
|
||||
self.server.set_response(text);
|
||||
}
|
||||
|
||||
/// Queue a byte-exact scripted response for the next request on `path`
|
||||
/// (e.g. `"/v1/responses"`). Consumed FIFO per path; falls back to the
|
||||
/// active fixed/echo mode when the queue is empty.
|
||||
pub fn enqueue_response(&self, path: impl Into<String>, response: ScriptedResponse) {
|
||||
self.server.enqueue_response(path, response);
|
||||
}
|
||||
|
||||
/// Access the underlying mock inference server for advanced scripting.
|
||||
pub fn server(&self) -> &MockInferenceServer {
|
||||
&self.server
|
||||
}
|
||||
|
||||
/// Pace the mocked SSE streams: each event is emitted after `delay`.
|
||||
/// `None` restores instant streaming. Use to hold a turn visibly
|
||||
/// "streaming" long enough to interact with it (e.g. Esc-cancel tests).
|
||||
pub fn set_chunk_delay(&self, delay: Option<std::time::Duration>) {
|
||||
self.server.set_chunk_delay(delay);
|
||||
}
|
||||
|
||||
/// Hold every agent turn's completion until [`release_agent_completions`]
|
||||
/// is called. Keeps a turn deterministically "streaming" so a test can
|
||||
/// interact with it (queue edits/removals) without racing turn end.
|
||||
///
|
||||
/// [`release_agent_completions`]: Self::release_agent_completions
|
||||
pub fn hold_agent_completions(&self) {
|
||||
self.server.hold_agent_completions();
|
||||
}
|
||||
|
||||
/// Release a hold set by [`hold_agent_completions`], letting the gated
|
||||
/// turn complete.
|
||||
///
|
||||
/// [`hold_agent_completions`]: Self::hold_agent_completions
|
||||
pub fn release_agent_completions(&self) {
|
||||
self.server.release_agent_completions();
|
||||
}
|
||||
|
||||
/// Queue one response per agent turn (FIFO) so each carries a distinct
|
||||
/// sentinel. See [`MockInferenceServer::set_agent_turns`].
|
||||
pub fn set_turns(&self, turns: impl IntoIterator<Item = String>) {
|
||||
self.server.set_agent_turns(turns);
|
||||
}
|
||||
|
||||
/// Number of inference requests the pager has made so far.
|
||||
pub fn request_count(&self) -> u32 {
|
||||
self.server.request_count()
|
||||
}
|
||||
|
||||
/// Whether the server has seen a chat completion request.
|
||||
pub fn has_chat_completion(&self) -> bool {
|
||||
self.server.has_chat_completion_request() || self.server.has_responses_request()
|
||||
}
|
||||
|
||||
/// Snapshot of all received requests — useful for test diagnostics.
|
||||
pub fn requests(&self) -> Vec<LogEntry> {
|
||||
self.server.requests()
|
||||
}
|
||||
|
||||
pub fn request_bodies(&self) -> Vec<serde_json::Value> {
|
||||
self.server.request_bodies()
|
||||
}
|
||||
|
||||
// ── Mock storage controls (park-on-401 e2e) ────────────────────────────
|
||||
|
||||
/// Flip the mock `/v1/storage` 401 gate (the auth-outage window).
|
||||
pub fn set_storage_unauthorized(&self, unauthorized: bool) {
|
||||
self.server.set_storage_unauthorized(unauthorized);
|
||||
}
|
||||
|
||||
/// Total `/v1/storage` upload attempts, including 401-rejected ones.
|
||||
pub fn storage_request_count(&self) -> u32 {
|
||||
self.server.storage_request_count()
|
||||
}
|
||||
|
||||
/// Snapshot of accepted (HTTP 200) `/v1/storage` uploads.
|
||||
pub fn storage_uploads(&self) -> Vec<StorageUpload> {
|
||||
self.server.storage_uploads()
|
||||
}
|
||||
}
|
||||
|
||||
fn default_response_text() -> String {
|
||||
"Hello from the pty_harness mock inference server.".to_owned()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The pre-delegation mock always served 200 `{"allow_access": true}`;
|
||||
/// the shared server defaults to 404-until-set. A 404 strands the pager
|
||||
/// on the SuperGrok upsell screen and breaks every PTY test.
|
||||
#[tokio::test]
|
||||
async fn settings_endpoint_allows_access_by_default() {
|
||||
let content = ContentController::start().await.unwrap();
|
||||
|
||||
let resp = reqwest::get(format!("{}/settings", content.url()))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
let body: serde_json::Value = resp.json().await.unwrap();
|
||||
assert_eq!(body, serde_json::json!({ "allow_access": true }));
|
||||
}
|
||||
|
||||
/// The pre-delegation mock streamed a fixed default text to every
|
||||
/// request; the shared server defaults to echo.
|
||||
#[tokio::test]
|
||||
async fn default_response_streams_fixed_text() {
|
||||
let content = ContentController::start().await.unwrap();
|
||||
|
||||
let body = reqwest::Client::new()
|
||||
.post(format!("{}/chat/completions", content.url()))
|
||||
.json(&serde_json::json!({
|
||||
"model": "test-model",
|
||||
"messages": [{ "role": "user", "content": "anything" }]
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.text()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let streamed: String = body
|
||||
.lines()
|
||||
.filter_map(|l| l.strip_prefix("data:"))
|
||||
.map(str::trim_start)
|
||||
.filter(|d| *d != "[DONE]")
|
||||
.filter_map(|d| serde_json::from_str::<serde_json::Value>(d).ok())
|
||||
.filter_map(|v| {
|
||||
v.get("choices")
|
||||
.and_then(|c| c.get(0))
|
||||
.and_then(|c| c.get("delta"))
|
||||
.and_then(|d| d.get("content"))
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(String::from)
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(streamed, default_response_text());
|
||||
assert!(content.has_chat_completion());
|
||||
}
|
||||
|
||||
/// `env_for_pager` keeps the exact sandbox + endpoint env contract the
|
||||
/// pager spawn path depends on.
|
||||
#[tokio::test]
|
||||
async fn env_for_pager_shape() {
|
||||
let content = ContentController::start().await.unwrap();
|
||||
let env = content.env_for_pager();
|
||||
let get = |k: &str| {
|
||||
env.iter()
|
||||
.find(|(key, _)| key.as_str() == k)
|
||||
.map(|(_, v)| v.clone())
|
||||
};
|
||||
|
||||
assert_eq!(get("HOME").as_deref(), content.home().to_str());
|
||||
assert_eq!(
|
||||
get("KIGI_SHARE_DIR").as_deref(),
|
||||
content.home().join(".kigi").to_str()
|
||||
);
|
||||
assert_eq!(get("KIGI_CLI_CHAT_PROXY_BASE_URL"), Some(content.url()));
|
||||
assert_eq!(get("KIGI_XAI_API_BASE_URL"), Some(content.url()));
|
||||
assert_eq!(get("XAI_API_KEY").as_deref(), Some("test-key-for-ci"));
|
||||
assert_eq!(get("KIGI_TELEMETRY_ENABLED").as_deref(), Some("false"));
|
||||
assert_eq!(get("KIGI_FEEDBACK_ENABLED").as_deref(), Some("false"));
|
||||
assert_eq!(get("KIGI_TRACE_UPLOAD").as_deref(), Some("false"));
|
||||
assert_eq!(get("KIGI_PROMPT_SUGGESTIONS").as_deref(), Some("false"));
|
||||
assert_eq!(get("KIGI_MAX_RETRIES").as_deref(), Some("0"));
|
||||
assert_eq!(env.len(), 10, "env list must not silently grow or shrink");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
//! Environment helpers for benchmarking and testing.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
|
||||
fn workspace_root() -> Result<PathBuf> {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.ancestors()
|
||||
.nth(3)
|
||||
.map(|p| p.to_path_buf())
|
||||
.context("failed to resolve workspace root from CARGO_MANIFEST_DIR")
|
||||
}
|
||||
|
||||
fn target_dir() -> Result<PathBuf> {
|
||||
Ok(std::env::var_os("CARGO_TARGET_DIR")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| {
|
||||
workspace_root()
|
||||
.expect("workspace root for target_dir fallback")
|
||||
.join("target")
|
||||
}))
|
||||
}
|
||||
|
||||
fn local_pager_binary_path() -> Result<PathBuf> {
|
||||
Ok(target_dir()?
|
||||
.join("debug")
|
||||
.join(format!("kigi-tui{}", std::env::consts::EXE_SUFFIX)))
|
||||
}
|
||||
|
||||
fn ensure_local_pager_binary(binary: &std::path::Path) -> Result<()> {
|
||||
if binary.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".to_owned());
|
||||
let mut cmd = Command::new(&cargo);
|
||||
cmd.current_dir(workspace_root()?)
|
||||
.args(["build", "-p", "kigi-bin", "--bin", "kigi-tui"])
|
||||
.stdin(Stdio::null())
|
||||
.envs(kigi_tty_utils::pager_env());
|
||||
kigi_tty_utils::detach_std_command(&mut cmd);
|
||||
let output = cmd
|
||||
.output()
|
||||
.with_context(|| format!("failed to spawn {cargo} to build kigi-tui"))?;
|
||||
|
||||
if !output.status.success() {
|
||||
bail!(
|
||||
"failed to build kigi-tui (exit {:?})\nstdout:\n{}\nstderr:\n{}",
|
||||
output.status.code(),
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr),
|
||||
);
|
||||
}
|
||||
if !binary.exists() {
|
||||
bail!(
|
||||
"kigi-tui build completed but binary missing at {}",
|
||||
binary.display()
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolve the pager binary path.
|
||||
///
|
||||
/// Resolution order:
|
||||
/// 1. `PAGER_BINARY` env var (for CI / explicit override)
|
||||
/// 2. `CARGO_BIN_EXE_kigi-tui` (set by `cargo test`)
|
||||
/// 3. Build locally via `cargo build -p kigi-bin` (the composition-
|
||||
/// root package that owns the `kigi-tui` binary)
|
||||
pub fn pager_binary() -> Result<PathBuf> {
|
||||
if let Ok(path) = std::env::var("PAGER_BINARY") {
|
||||
let p = PathBuf::from(path);
|
||||
if !p.exists() {
|
||||
bail!("PAGER_BINARY does not exist: {}", p.display());
|
||||
}
|
||||
// Bazel sets PAGER_BINARY to a runfiles-relative path; portable_pty
|
||||
// resolves non-absolute paths via PATH lookup instead of the cwd.
|
||||
return std::path::absolute(&p)
|
||||
.with_context(|| format!("failed to absolutize PAGER_BINARY: {}", p.display()));
|
||||
}
|
||||
|
||||
if let Ok(path) = std::env::var("CARGO_BIN_EXE_kigi-tui") {
|
||||
let p = PathBuf::from(path);
|
||||
if p.exists() {
|
||||
return Ok(p);
|
||||
}
|
||||
}
|
||||
|
||||
let binary = local_pager_binary_path()?;
|
||||
ensure_local_pager_binary(&binary)?;
|
||||
Ok(binary)
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
//! Cross-suite e2e flow helpers over [`PtyHarness`] / [`ContentController`].
|
||||
//!
|
||||
//! The single canonical home for driving/seeding helpers shared by the
|
||||
//! pager's `pty_e2e` and `leader_pty_e2e` test targets (both depend on this
|
||||
//! crate); suite-local constants (sizes, sentinels, timeouts) stay in each
|
||||
//! suite's `common.rs`.
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::{ContentController, PtyHarness};
|
||||
|
||||
/// Pump PTY output until every label is absent from the visible screen.
|
||||
pub fn wait_for_labels_absent(h: &mut PtyHarness, labels: &[&str], timeout: Duration) {
|
||||
let _ = h.wait_until("screen labels to disappear", timeout, |h| {
|
||||
labels.iter().all(|label| !h.contains_text(label))
|
||||
});
|
||||
}
|
||||
|
||||
/// Submit `prompt` from `h`, then keep re-pressing Enter until the turn
|
||||
/// actually starts streaming (`sentinel` appears) or `timeout` elapses.
|
||||
///
|
||||
/// In a heavy multi-client leader cluster the driver's submit Enter can be
|
||||
/// dropped when it races the other client attaching / replaying on the shared
|
||||
/// leader: the typed prompt is left sitting unsubmitted in the composer, the
|
||||
/// turn never starts, and a plain `wait_for_text` then times out (the observed
|
||||
/// `leader_two_clients_shared_session` flake — A idle with `again` still in the
|
||||
/// composer at 75s). Re-pressing Enter is safe and idempotent: submitting takes
|
||||
/// the composer draft synchronously (`std::mem::take` in `dispatch`), so once a
|
||||
/// turn has really been sent the composer is empty and an extra Enter is a
|
||||
/// no-op. It can only submit a still-stuck prompt, never double-submit a sent
|
||||
/// one (which would break exactly-once scrollback asserts).
|
||||
pub fn submit_turn(h: &mut PtyHarness, prompt: &str, sentinel: &str, timeout: Duration) {
|
||||
h.inject_keys(format!("{prompt}\r").as_bytes())
|
||||
.expect("inject prompt submit");
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
let remaining = deadline.saturating_duration_since(Instant::now());
|
||||
// Per-attempt sub-budget, generous enough that a genuinely in-flight
|
||||
// submit resolves before we re-nudge (so the re-nudge only ever fires
|
||||
// on an empty composer, where it is a no-op).
|
||||
if h.wait_for_text(sentinel, Duration::from_secs(10).min(remaining))
|
||||
.is_ok()
|
||||
{
|
||||
return;
|
||||
}
|
||||
assert!(
|
||||
Instant::now() < deadline,
|
||||
"timed out after {timeout:?} waiting for {sentinel:?}\nscreen:\n{}",
|
||||
h.screen_contents()
|
||||
);
|
||||
let _ = h.inject_keys(b"\r");
|
||||
}
|
||||
}
|
||||
|
||||
/// Count only inference requests (chat completions / responses / messages),
|
||||
/// ignoring incidental GETs like /v1/models and /v1/settings, so a replay
|
||||
/// invariant means "no turn was re-driven" rather than "no HTTP at all".
|
||||
pub fn inference_request_count(content: &ContentController) -> usize {
|
||||
content
|
||||
.requests()
|
||||
.iter()
|
||||
.filter(|e| {
|
||||
e.path.contains("/chat/completions")
|
||||
|| e.path.contains("/responses")
|
||||
|| e.path.contains("/messages")
|
||||
})
|
||||
.count()
|
||||
}
|
||||
|
||||
/// Seed a fake xAI OAuth entry into the isolated home's `auth.json` so the
|
||||
/// shell has session auth (the harness's `XAI_API_KEY` is ApiKey/BYOK mode
|
||||
/// and never enters the auth manager). Load-bearing details: the scope key
|
||||
/// must be `<issuer>::<client_id>`, `auth_mode` must be `oidc`, and
|
||||
/// `expires_at` must be far-future so no network refresh is attempted; the
|
||||
/// mock server accepts any bearer. Pair with [`oauth_env_for_pager`].
|
||||
pub fn seed_fake_oauth(content: &ContentController, user: &str) {
|
||||
let kigi_home = content.home().join(".kigi");
|
||||
std::fs::create_dir_all(&kigi_home).expect("create temp .kigi");
|
||||
std::fs::write(
|
||||
kigi_home.join("auth.json"),
|
||||
format!(
|
||||
r#"{{
|
||||
"https://auth.x.ai::b1a00492-073a-47ea-816f-4c329264a828": {{
|
||||
"key": "pty-test-oauth-token",
|
||||
"auth_mode": "oidc",
|
||||
"create_time": "2026-01-01T00:00:00Z",
|
||||
"user_id": "{user}",
|
||||
"email": "{user}@test.invalid",
|
||||
"expires_at": "2030-01-01T00:00:00Z",
|
||||
"refresh_token": "pty-test-refresh-token",
|
||||
"oidc_issuer": "https://auth.x.ai",
|
||||
"oidc_client_id": "b1a00492-073a-47ea-816f-4c329264a828"
|
||||
}}
|
||||
}}"#
|
||||
),
|
||||
)
|
||||
.expect("seed fake oauth auth.json");
|
||||
}
|
||||
|
||||
/// [`ContentController::env_for_pager`] minus `XAI_API_KEY`, so the entry
|
||||
/// written by [`seed_fake_oauth`] is the active credential.
|
||||
pub fn oauth_env_for_pager(content: &ContentController) -> Vec<(String, String)> {
|
||||
let mut env = content.env_for_pager();
|
||||
env.retain(|(k, _)| k != "XAI_API_KEY");
|
||||
env
|
||||
}
|
||||
|
||||
/// Drive `/new` until `model` shows on screen. Campaigns apply to **new
|
||||
/// sessions only** and the pager's settings prefetch is deliberately 2s-capped,
|
||||
/// so on a loaded runner the first session can legitimately open pre-campaign;
|
||||
/// each `/new` after the settings fetch lands re-resolves with the campaign.
|
||||
pub fn wait_for_model_via_new_sessions(h: &mut PtyHarness, model: &str, timeout: Duration) -> bool {
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
if h.contains_text(model) {
|
||||
return true;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return false;
|
||||
}
|
||||
let _ = h.inject_keys(b"/new\r");
|
||||
h.update(Duration::from_millis(3000));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
//! REAL host-clipboard plumbing shared by the OS-native paste e2e tests and
|
||||
//! the `paste_latency` bench: `pbcopy` / `pbpaste` / `osascript` on macOS,
|
||||
//! PowerShell `Set-Clipboard` / `Get-Clipboard` / WinForms `SetImage` on
|
||||
//! Windows.
|
||||
//!
|
||||
//! Everything here mutates or reads the MACHINE-GLOBAL clipboard, so callers
|
||||
//! must serialize against each other (e.g. `#[serial_test::serial]`) and
|
||||
//! should hold a [`HostClipboardTextGuard`] to restore the prior text. CI
|
||||
//! sessions without a usable clipboard are detected via
|
||||
//! [`clipboard_roundtrip_works`] so tests can skip instead of fail.
|
||||
//!
|
||||
//! Compiles on every platform so cross-platform builds of the consumers stay
|
||||
//! green (the bench gates at runtime); on unsupported hosts the tool spawns
|
||||
//! simply fail.
|
||||
|
||||
use std::io::Write as _;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
|
||||
/// Copy `text` to the host clipboard via `pbcopy`.
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
pub fn pbcopy(text: &str) -> Result<()> {
|
||||
let mut cmd = Command::new("pbcopy");
|
||||
cmd.stdin(Stdio::piped());
|
||||
kigi_tty_utils::detach_std_command(&mut cmd);
|
||||
let mut child = cmd.spawn().context("spawn pbcopy")?;
|
||||
child
|
||||
.stdin
|
||||
.take()
|
||||
.context("pbcopy stdin")?
|
||||
.write_all(text.as_bytes())
|
||||
.context("write pbcopy stdin")?;
|
||||
let status = child.wait().context("wait pbcopy")?;
|
||||
if !status.success() {
|
||||
bail!("pbcopy exited with {status}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Copy `text` to the host clipboard via PowerShell `Set-Clipboard`.
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn pbcopy(text: &str) -> Result<()> {
|
||||
// Text travels over stdin, never inside the command line — no quoting.
|
||||
let mut cmd = Command::new("powershell");
|
||||
cmd.args([
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-Command",
|
||||
"Set-Clipboard -Value ([Console]::In.ReadToEnd())",
|
||||
])
|
||||
.stdin(Stdio::piped());
|
||||
kigi_tty_utils::detach_std_command(&mut cmd);
|
||||
let mut child = cmd.spawn().context("spawn powershell Set-Clipboard")?;
|
||||
child
|
||||
.stdin
|
||||
.take()
|
||||
.context("powershell stdin")?
|
||||
.write_all(text.as_bytes())
|
||||
.context("write powershell stdin")?;
|
||||
let status = child.wait().context("wait powershell Set-Clipboard")?;
|
||||
if !status.success() {
|
||||
bail!("powershell Set-Clipboard exited with {status}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Current host clipboard TEXT via `pbpaste` (`None` when unavailable).
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
pub fn pbpaste() -> Option<String> {
|
||||
let mut cmd = Command::new("pbpaste");
|
||||
kigi_tty_utils::detach_std_command(&mut cmd);
|
||||
let out = cmd.output().ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
Some(String::from_utf8_lossy(&out.stdout).into_owned())
|
||||
}
|
||||
|
||||
/// Current host clipboard TEXT via PowerShell `Get-Clipboard` (`None` when
|
||||
/// unavailable).
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn pbpaste() -> Option<String> {
|
||||
let mut cmd = Command::new("powershell");
|
||||
// Console::Out.Write avoids the trailing newline PowerShell's pipeline
|
||||
// output would append (roundtrip checks compare exact text).
|
||||
cmd.args([
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-Command",
|
||||
"$c = Get-Clipboard -Raw; if ($null -ne $c) { [Console]::Out.Write($c) }",
|
||||
]);
|
||||
kigi_tty_utils::detach_std_command(&mut cmd);
|
||||
let out = cmd.output().ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
Some(String::from_utf8_lossy(&out.stdout).into_owned())
|
||||
}
|
||||
|
||||
/// Put the PNG at `path` on the host clipboard as a raster (`«class PNGf»`).
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
pub fn set_clipboard_png(path: &Path) -> Result<()> {
|
||||
let script = format!(
|
||||
"set the clipboard to (read (POSIX file \"{}\") as «class PNGf»)",
|
||||
path.display()
|
||||
);
|
||||
let mut cmd = Command::new("osascript");
|
||||
cmd.arg("-e").arg(&script);
|
||||
kigi_tty_utils::detach_std_command(&mut cmd);
|
||||
let status = cmd.status().context("spawn osascript")?;
|
||||
if !status.success() {
|
||||
bail!("osascript set-clipboard-PNG exited with {status}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Put the PNG at `path` on the host clipboard as a raster via a WinForms
|
||||
/// `Clipboard::SetImage` PowerShell one-liner.
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn set_clipboard_png(path: &Path) -> Result<()> {
|
||||
use base64::Engine as _;
|
||||
// WinForms Clipboard requires an STA thread; -EncodedCommand (base64 of
|
||||
// UTF-16LE) sidesteps every cmd/PowerShell quoting layer, leaving only
|
||||
// the PS single-quote escape for the embedded path.
|
||||
let ps_path = path.display().to_string().replace('\'', "''");
|
||||
let script = format!(
|
||||
"Add-Type -AssemblyName System.Windows.Forms,System.Drawing; \
|
||||
$img = [System.Drawing.Image]::FromFile('{ps_path}'); \
|
||||
[System.Windows.Forms.Clipboard]::SetImage($img); \
|
||||
$img.Dispose()"
|
||||
);
|
||||
let utf16: Vec<u8> = script
|
||||
.encode_utf16()
|
||||
.flat_map(|u| u.to_le_bytes())
|
||||
.collect();
|
||||
let encoded = base64::engine::general_purpose::STANDARD.encode(utf16);
|
||||
let mut cmd = Command::new("powershell");
|
||||
cmd.args([
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-STA",
|
||||
"-EncodedCommand",
|
||||
&encoded,
|
||||
]);
|
||||
kigi_tty_utils::detach_std_command(&mut cmd);
|
||||
let status = cmd.status().context("spawn powershell SetImage")?;
|
||||
if !status.success() {
|
||||
bail!("powershell SetImage exited with {status}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write a small solid-color PNG under `dir` and return its path.
|
||||
pub fn write_fixture_png(dir: &Path) -> Result<PathBuf> {
|
||||
let path = dir.join("host_clipboard_fixture.png");
|
||||
let buf: image::ImageBuffer<image::Rgba<u8>, Vec<u8>> =
|
||||
image::ImageBuffer::from_pixel(64, 64, image::Rgba([200, 40, 120, 255]));
|
||||
buf.save(&path)
|
||||
.context("write host clipboard fixture png")?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Whether the host clipboard actually works in this session: sets a nonce
|
||||
/// via the text helper and reads it back. False on clipboard-less CI sessions
|
||||
/// (e.g. a Windows service session with no interactive desktop) so tests can
|
||||
/// SKIP loudly instead of failing on environment.
|
||||
pub fn clipboard_roundtrip_works() -> bool {
|
||||
let nonce = format!("HOSTCLIPROUNDTRIP{}", std::process::id());
|
||||
if pbcopy(&nonce).is_err() {
|
||||
return false;
|
||||
}
|
||||
// trim_end: some clipboard tool chains append a trailing newline.
|
||||
pbpaste().is_some_and(|t| t.trim_end() == nonce)
|
||||
}
|
||||
|
||||
/// Best-effort save/restore of the host TEXT clipboard around a test or bench
|
||||
/// run. Restores on drop (panic/unwind included). A prior IMAGE clipboard
|
||||
/// cannot be restored — `pbpaste` only reads the text representation.
|
||||
pub struct HostClipboardTextGuard {
|
||||
prior: Option<String>,
|
||||
}
|
||||
|
||||
impl HostClipboardTextGuard {
|
||||
pub fn save() -> Self {
|
||||
Self { prior: pbpaste() }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for HostClipboardTextGuard {
|
||||
fn drop(&mut self) {
|
||||
if let Some(prior) = self.prior.take() {
|
||||
// Non-panicking restore: a panicking test must still unwind cleanly.
|
||||
let _ = pbcopy(&prior);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
//! Multi-client leader cluster: one shared leader, N pager clients, plus
|
||||
//! inspection of the durable session log so reattach tests can assert on
|
||||
//! what actually persisted.
|
||||
//!
|
||||
//! Every other leader test is single-client/single-leader; [`LeaderCluster`]
|
||||
//! is the missing abstraction for "one leader, several pager clients sharing
|
||||
//! its session". One [`ContentController`] gives one shared `$HOME` (hence one
|
||||
//! elected leader) plus a fixed leader socket beneath its `KIGI_SHARE_DIR`; clients
|
||||
//! spawn with the `--leader`/`--leader-socket` flags so they all attach to the
|
||||
//! SAME leader. It also exposes the leader's durable `updates.jsonl` log so a
|
||||
//! reattach test can assert on the persisted, replayable turn-completion
|
||||
//! records — the genuine end-to-end signal behind durable turn completion.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{ContentController, PtyHarness, pager_binary};
|
||||
|
||||
/// One shared mock-backed leader plus the pager clients attached to it.
|
||||
pub struct LeaderCluster {
|
||||
content: ContentController,
|
||||
binary: PathBuf,
|
||||
socket: PathBuf,
|
||||
rows: u16,
|
||||
cols: u16,
|
||||
}
|
||||
|
||||
impl LeaderCluster {
|
||||
/// Start the cluster: one [`ContentController`] (one shared `$HOME` =>
|
||||
/// one leader) and a fixed leader socket under its `KIGI_SHARE_DIR`.
|
||||
pub async fn start(rows: u16, cols: u16) -> Result<Self> {
|
||||
let content = ContentController::start()
|
||||
.await
|
||||
.context("start content controller")?;
|
||||
// One shared KIGI_SHARE_DIR => one leader; the socket lives beneath it so
|
||||
// every client (sharing the same env) elects/attaches to the same one.
|
||||
let kigi_home = content.home().join(".kigi");
|
||||
std::fs::create_dir_all(&kigi_home).context("create grok home")?;
|
||||
let socket = kigi_home.join("leader-e2e.sock");
|
||||
let binary = pager_binary().context("resolve pager binary")?;
|
||||
Ok(Self {
|
||||
content,
|
||||
binary,
|
||||
socket,
|
||||
rows,
|
||||
cols,
|
||||
})
|
||||
}
|
||||
|
||||
/// Spawn the leader-electing client (`--leader --leader-socket <S>` plus
|
||||
/// `extra_args`); it starts a fresh session and brings up the leader.
|
||||
pub fn spawn_leader(&self, extra_args: &[&str]) -> Result<PtyHarness> {
|
||||
self.spawn_client(&[], extra_args)
|
||||
}
|
||||
|
||||
/// Attach another client that resumes the shared session through the SAME
|
||||
/// leader (`--leader --leader-socket <S> --resume` plus `extra_args`).
|
||||
pub fn attach(&self, extra_args: &[&str]) -> Result<PtyHarness> {
|
||||
self.spawn_client(&["--resume"], extra_args)
|
||||
}
|
||||
|
||||
/// Spawn a client wired to the shared leader socket. `mode_args` carries
|
||||
/// the per-role flag (`--resume` for attachers); `extra_args` is the
|
||||
/// caller's.
|
||||
fn spawn_client(&self, mode_args: &[&str], extra_args: &[&str]) -> Result<PtyHarness> {
|
||||
let socket = self.socket.to_str().context("socket path is utf-8")?;
|
||||
let mut args: Vec<&str> = vec!["--leader", "--leader-socket", socket];
|
||||
args.extend_from_slice(mode_args);
|
||||
args.extend_from_slice(extra_args);
|
||||
PtyHarness::spawn_with_content(&self.binary, self.rows, self.cols, &self.content, &args)
|
||||
.context("spawn pager client on shared leader")
|
||||
}
|
||||
|
||||
/// The shared content controller (mock inference server + sandbox env).
|
||||
pub fn content(&self) -> &ContentController {
|
||||
&self.content
|
||||
}
|
||||
|
||||
/// The cluster's sessions root: `KIGI_SHARE_DIR/sessions` (layout below is
|
||||
/// `sessions/<encoded-cwd>/<session-id>/updates.jsonl`).
|
||||
fn sessions_dir(&self) -> PathBuf {
|
||||
self.content.home().join(".kigi").join("sessions")
|
||||
}
|
||||
|
||||
/// The session-update payload of every record across every `updates.jsonl`
|
||||
/// under the cluster's [`sessions_dir`](Self::sessions_dir) — i.e. the
|
||||
/// `params.update` object of each persisted envelope line, so a caller can
|
||||
/// match on its `sessionUpdate` tag directly. Scans ALL sessions under the
|
||||
/// cluster (fine for the single-session clusters these tests build).
|
||||
///
|
||||
/// Infallible by design: a file that vanishes mid-walk, or whose appended
|
||||
/// tail tore across a multi-byte UTF-8 boundary (so `read_to_string`
|
||||
/// fails), is skipped for this call and picked up on the next one.
|
||||
pub fn session_updates(&self) -> Vec<Value> {
|
||||
let mut files = Vec::new();
|
||||
collect_updates_files(&self.sessions_dir(), &mut files);
|
||||
let mut out = Vec::new();
|
||||
for file in files {
|
||||
// Skip a vanished file or a torn multi-byte tail; the next poll retries.
|
||||
if let Ok(text) = std::fs::read_to_string(&file) {
|
||||
out.extend(parse_update_payloads(&text));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Poll [`session_updates`](Self::session_updates) until a record with
|
||||
/// `sessionUpdate == "turn_completed"` appears, returning that (inner)
|
||||
/// update payload, or error on timeout. Scans ALL sessions under the
|
||||
/// cluster (fine for the single-session clusters these tests build).
|
||||
pub fn wait_for_turn_completed(&self, timeout: Duration) -> Result<Value> {
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
let updates = self.session_updates();
|
||||
if let Some(rec) = updates.iter().find(|u| is_turn_completed(u)) {
|
||||
return Ok(rec.clone());
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
// Surface what WAS persisted so "zero records / env problem" is
|
||||
// distinguishable from "records present but no turn_completed /
|
||||
// producer regression".
|
||||
let tags: std::collections::BTreeSet<&str> = updates
|
||||
.iter()
|
||||
.filter_map(|u| u.get("sessionUpdate").and_then(Value::as_str))
|
||||
.collect();
|
||||
anyhow::bail!(
|
||||
"timed out after {timeout:?} waiting for a turn_completed record under {}; \
|
||||
saw {} update record(s) with sessionUpdate tags {tags:?}",
|
||||
self.sessions_dir().display(),
|
||||
updates.len(),
|
||||
);
|
||||
}
|
||||
// Sync FS poll mirrors the harness's blocking wait_for_text; a stat
|
||||
// every 150ms is cheap and fine on a multi_thread runtime worker.
|
||||
std::thread::sleep(Duration::from_millis(150));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a session-update payload is a `turn_completed` terminal.
|
||||
fn is_turn_completed(update: &Value) -> bool {
|
||||
update.get("sessionUpdate").and_then(Value::as_str) == Some("turn_completed")
|
||||
}
|
||||
|
||||
/// Parse the `params.update` payload out of each non-blank line of an
|
||||
/// `updates.jsonl` body, assuming the enveloped on-disk shape current sessions
|
||||
/// always write (`{..,"params":{"update":{..}}}`). A line that is blank, fails
|
||||
/// to parse (a torn trailing line that is still valid UTF-8), or carries no
|
||||
/// `params.update` is skipped — never failing the batch. (A torn *multi-byte*
|
||||
/// tail instead fails the file read upstream, skipping the whole file for that
|
||||
/// poll; see [`LeaderCluster::session_updates`].)
|
||||
fn parse_update_payloads(text: &str) -> Vec<Value> {
|
||||
text.lines()
|
||||
.filter_map(|line| {
|
||||
let line = line.trim();
|
||||
if line.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let envelope: Value = serde_json::from_str(line).ok()?;
|
||||
envelope.get("params")?.get("update").cloned()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Recursively collect every `updates.jsonl` beneath `dir` (a manual walk to
|
||||
/// avoid a new crate dep). A missing/unreadable dir yields nothing — sessions
|
||||
/// may not exist yet, and the walk is re-run on every poll.
|
||||
fn collect_updates_files(dir: &Path, out: &mut Vec<PathBuf>) {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
// No-follow file type: a symlinked directory has `is_dir() == false`,
|
||||
// so a symlink cycle can never recurse forever here.
|
||||
let Ok(file_type) = entry.file_type() else {
|
||||
continue;
|
||||
};
|
||||
let path = entry.path();
|
||||
if file_type.is_dir() {
|
||||
collect_updates_files(&path, out);
|
||||
} else if path.file_name().and_then(|n| n.to_str()) == Some("updates.jsonl") {
|
||||
out.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Wrap a session update as the envelope stored in `updates.jsonl`.
|
||||
fn envelope(update_json: &str) -> String {
|
||||
format!(
|
||||
r#"{{"timestamp":1,"method":"_x.ai/session/update","params":{{"sessionId":"s","update":{update_json}}}}}"#
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_update_payloads_unwraps_and_tolerates_torn_trailing_line() {
|
||||
let body = format!(
|
||||
"{}\n{}\n{{\"timestamp\":2,\"method\":\"_x.ai/sess",
|
||||
envelope(
|
||||
r#"{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"hi"}}"#
|
||||
),
|
||||
envelope(
|
||||
r#"{"sessionUpdate":"turn_completed","prompt_id":"p-1","stop_reason":"end_turn"}"#
|
||||
),
|
||||
);
|
||||
let updates = parse_update_payloads(&body);
|
||||
// The two complete lines parse; the torn final line is dropped.
|
||||
assert_eq!(updates.len(), 2);
|
||||
let completed = updates
|
||||
.iter()
|
||||
.find(|u| is_turn_completed(u))
|
||||
.expect("turn_completed payload is unwrapped from params.update");
|
||||
assert_eq!(completed["stop_reason"], "end_turn");
|
||||
assert_eq!(completed["prompt_id"], "p-1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_update_payloads_skips_blank_and_payloadless_lines() {
|
||||
let body = format!(
|
||||
"\n \n{}\n{{\"timestamp\":3,\"method\":\"x\",\"params\":{{\"sessionId\":\"s\"}}}}\n",
|
||||
envelope(
|
||||
r#"{"sessionUpdate":"turn_completed","prompt_id":"p","stop_reason":"cancelled"}"#
|
||||
),
|
||||
);
|
||||
let updates = parse_update_payloads(&body);
|
||||
// Only the one envelope carrying params.update survives.
|
||||
assert_eq!(updates.len(), 1);
|
||||
assert!(is_turn_completed(&updates[0]));
|
||||
assert_eq!(updates[0]["stop_reason"], "cancelled");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,635 @@
|
||||
//! Unified PTY harness for kigi-tui.
|
||||
//!
|
||||
//! The same layered API serves three consumers:
|
||||
//!
|
||||
//! 1. **Regression scenarios** (e.g. `scenarios::plan_approval_resume`,
|
||||
//! exercised via `tests/` in this crate and `pty-scenario` YAML under
|
||||
//! `kigi-tui/tests/scenarios/`) — assert screen contents and
|
||||
//! multi-process resume behavior.
|
||||
//! 2. **Benchmarks** (`benches/pty_bench.rs`) — run timing scenarios, collect
|
||||
//! per-frame timings, emit JSON / compare against baselines.
|
||||
//! 3. **Ad-hoc scenario runs** — spin up the harness to reproduce issues locally.
|
||||
//!
|
||||
//! ## Layers
|
||||
//!
|
||||
//! - **`pty`** (L1) — PTY management (spawn, inject keys, resize, drain).
|
||||
//! - **`screen`** (L2a) — Virtual terminal state via `alacritty_terminal` ("what the user sees").
|
||||
//! - **`timing`** (L2b) — Per-frame durations via `?2026 h/l` markers.
|
||||
//! - **`content`** (L3) — Mock inference server driving real content into the pager.
|
||||
//! - **`scenarios`** — Named, parameterised workloads returning `BenchResults`.
|
||||
//! - **`results`** — Aggregated statistics, baseline compare.
|
||||
//! - **`scroll_matrix`** — `KIGI_SCROLL_LOG` JSONL ingestion for the scroll validation matrix.
|
||||
//! - **`env`** — Binary resolution and workspace path helpers.
|
||||
//! - **`flows`** — Cross-suite drive/seed helpers shared by the pager's e2e targets.
|
||||
|
||||
pub mod content;
|
||||
pub mod env;
|
||||
pub mod flows;
|
||||
pub mod host_clipboard;
|
||||
pub mod leader;
|
||||
pub mod pty;
|
||||
pub mod results;
|
||||
pub mod scenarios;
|
||||
pub mod screen;
|
||||
pub mod scripted;
|
||||
pub mod scroll_matrix;
|
||||
pub mod timing;
|
||||
|
||||
pub use content::{ContentController, MockModel, ScriptedResponse, SseEvent, sse};
|
||||
pub use env::pager_binary;
|
||||
pub use flows::{
|
||||
inference_request_count, oauth_env_for_pager, seed_fake_oauth, submit_turn,
|
||||
wait_for_labels_absent, wait_for_model_via_new_sessions,
|
||||
};
|
||||
pub use host_clipboard::HostClipboardTextGuard;
|
||||
pub use leader::LeaderCluster;
|
||||
use pty::PtyRead;
|
||||
pub use pty::{PtyController, keys};
|
||||
pub use results::{BenchResults, compare_baseline};
|
||||
pub use scenarios::Scenario;
|
||||
pub use screen::ScreenTracker;
|
||||
pub use scripted::{
|
||||
BugFinding, BugSeverity, DimensionAssertion, EnvVar, EnvironmentConfig, ImageFixture,
|
||||
ImageFixtureKind, MockConfig, MouseButton, MousePoint, SGR_SCROLL_DOWN, SGR_SCROLL_UP,
|
||||
ScenarioStep, ScriptedRunConfig, ScriptedRunReport, ScriptedRunStatus, ScriptedScenario,
|
||||
ScriptedScenarioRunner, ScrollDirection, StepOutcome, StepStatus, TerminalConfig,
|
||||
VisualArtifact,
|
||||
};
|
||||
pub use timing::{FrameTiming, FrameTimingParser};
|
||||
|
||||
// Re-export ptyctl types for richer terminal emulation, vim key notation,
|
||||
// and styled output support.
|
||||
pub use ptyctl::keys::parse_keys;
|
||||
pub use ptyctl::styled::{StyledLine, StyledRun};
|
||||
pub use ptyctl::term::{ScreenOutput, Terminal as AlacrittyTerminal};
|
||||
|
||||
use std::path::Path;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use portable_pty::PtySize;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
enum PtyPump {
|
||||
Chunk,
|
||||
Timeout,
|
||||
Closed,
|
||||
}
|
||||
|
||||
/// High-level harness that composes PTY control, screen state, and frame timing.
|
||||
///
|
||||
/// The key method is [`update`](PtyHarness::update), which receives PTY output
|
||||
/// chunks inline and feeds each to **both** the [`ScreenTracker`] and
|
||||
/// [`FrameTimingParser`] as it arrives. This preserves inter-chunk timing for
|
||||
/// accurate frame measurement.
|
||||
pub struct PtyHarness {
|
||||
pty: PtyController,
|
||||
screen: ScreenTracker,
|
||||
timing: FrameTimingParser,
|
||||
raw_output: Vec<u8>,
|
||||
/// Spawn instant — the time origin for asciinema cast event timestamps.
|
||||
spawned_at: Instant,
|
||||
/// Per-chunk cast events as `(elapsed_secs, end_offset_into_raw_output)`.
|
||||
/// Each event's bytes are `raw_output[prev_end..end]`, so the chunks are
|
||||
/// not duplicated in memory.
|
||||
cast_events: Vec<(f64, usize)>,
|
||||
/// Terminal size at spawn as `(cols, rows)` for the cast header.
|
||||
cast_size: (u16, u16),
|
||||
/// When true, [`update`](Self::update) forwards terminal-generated replies
|
||||
/// (cursor-position reports, device attributes, …) back to the child.
|
||||
/// Off by default so tests that script their own probe replies (e.g.
|
||||
/// `pty_xtversion`) keep full control; minimal-mode tests turn it on so the
|
||||
/// inline viewport's startup cursor query completes instead of timing out.
|
||||
respond_to_queries: bool,
|
||||
}
|
||||
|
||||
impl PtyHarness {
|
||||
/// Spawn the pager in a PTY and create a new harness.
|
||||
///
|
||||
/// Both `rows` and `cols` follow terminal convention: `(rows, cols)`.
|
||||
pub fn new(
|
||||
binary: &Path,
|
||||
rows: u16,
|
||||
cols: u16,
|
||||
args: &[&str],
|
||||
env: &[(&str, &str)],
|
||||
) -> Result<Self> {
|
||||
Self::new_in_dir(binary, rows, cols, args, env, None)
|
||||
}
|
||||
|
||||
/// Like [`new`](Self::new), with an explicit working directory (`None` inherits).
|
||||
pub fn new_in_dir(
|
||||
binary: &Path,
|
||||
rows: u16,
|
||||
cols: u16,
|
||||
args: &[&str],
|
||||
env: &[(&str, &str)],
|
||||
cwd: Option<&Path>,
|
||||
) -> Result<Self> {
|
||||
let size = PtySize {
|
||||
rows,
|
||||
cols,
|
||||
pixel_width: 0,
|
||||
pixel_height: 0,
|
||||
};
|
||||
let pty = PtyController::spawn_in_dir(binary, size, args, env, cwd)
|
||||
.context("failed to spawn pager in PTY")?;
|
||||
|
||||
Ok(Self {
|
||||
pty,
|
||||
screen: ScreenTracker::new(rows, cols),
|
||||
timing: FrameTimingParser::new(),
|
||||
raw_output: Vec::new(),
|
||||
spawned_at: Instant::now(),
|
||||
cast_events: Vec::new(),
|
||||
cast_size: (cols, rows),
|
||||
respond_to_queries: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// Enable (or disable) forwarding terminal-generated replies back to the
|
||||
/// child during [`update`](Self::update). Real terminals answer device
|
||||
/// queries automatically; the harness leaves this off by default so probe
|
||||
/// tests can script their own replies. Minimal-mode tests enable it so the
|
||||
/// inline viewport's startup cursor-position query (`ESC[6n`) is answered
|
||||
/// and `--minimal` is not silently downgraded to full-screen inline.
|
||||
pub fn set_respond_to_queries(&mut self, enabled: bool) {
|
||||
self.respond_to_queries = enabled;
|
||||
}
|
||||
|
||||
/// Spawn the pager with env vars from a [`ContentController`] attached.
|
||||
///
|
||||
/// This is the common pattern for both e2e tests and benchmarks:
|
||||
///
|
||||
/// ```no_run
|
||||
/// # use std::time::Duration;
|
||||
/// # use kigi_pager_pty_harness::{PtyHarness, ContentController, pager_binary};
|
||||
/// # async fn example() -> anyhow::Result<()> {
|
||||
/// let content = ContentController::start().await?;
|
||||
/// content.set_response("# Hello\n\nAgent said hi.");
|
||||
///
|
||||
/// let mut harness = PtyHarness::spawn_with_content(
|
||||
/// &pager_binary()?, 50, 120, &content, &[],
|
||||
/// )?;
|
||||
/// harness.wait_for_text("Hello", Duration::from_secs(10))?;
|
||||
/// harness.quit()?;
|
||||
/// # Ok(()) }
|
||||
/// ```
|
||||
pub fn spawn_with_content(
|
||||
binary: &Path,
|
||||
rows: u16,
|
||||
cols: u16,
|
||||
content: &ContentController,
|
||||
extra_args: &[&str],
|
||||
) -> Result<Self> {
|
||||
Self::spawn_with_content_in_dir(binary, rows, cols, content, extra_args, None)
|
||||
}
|
||||
|
||||
/// Like [`spawn_with_content`](Self::spawn_with_content), with an explicit working directory.
|
||||
pub fn spawn_with_content_in_dir(
|
||||
binary: &Path,
|
||||
rows: u16,
|
||||
cols: u16,
|
||||
content: &ContentController,
|
||||
extra_args: &[&str],
|
||||
cwd: Option<&Path>,
|
||||
) -> Result<Self> {
|
||||
let env = content.env_for_pager();
|
||||
let env_refs: Vec<(&str, &str)> =
|
||||
env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
|
||||
Self::new_in_dir(binary, rows, cols, extra_args, &env_refs, cwd)
|
||||
}
|
||||
|
||||
// ── PTY control ──────────────────────────────────────────────────
|
||||
|
||||
/// Inject raw key bytes into the PTY.
|
||||
pub fn inject_keys(&mut self, keys: &[u8]) -> Result<()> {
|
||||
self.pty.inject_keys(keys)
|
||||
}
|
||||
|
||||
/// Resize the PTY and virtual screen. Arguments are `(rows, cols)`.
|
||||
pub fn resize(&mut self, rows: u16, cols: u16) -> Result<()> {
|
||||
self.pty.resize(rows, cols)?;
|
||||
self.screen.resize(rows, cols);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Update: receive PTY output inline → feed both parsers ────────
|
||||
|
||||
/// Receive PTY output for up to `timeout`, feeding each chunk to both
|
||||
/// the screen state tracker and the frame timing parser as it arrives.
|
||||
///
|
||||
/// Processing inline (rather than buffering all chunks first) preserves
|
||||
/// inter-chunk timing so that `FrameTimingParser` records accurate
|
||||
/// wall-clock frame durations.
|
||||
pub fn update(&mut self, timeout: Duration) {
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
let remaining = deadline.saturating_duration_since(Instant::now());
|
||||
if remaining.is_zero() || !matches!(self.pump_one(remaining), PtyPump::Chunk) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn pump_one(&mut self, timeout: Duration) -> PtyPump {
|
||||
match self.pty.recv_chunk(timeout) {
|
||||
PtyRead::Chunk(chunk) => {
|
||||
self.raw_output.extend_from_slice(&chunk);
|
||||
self.cast_events.push((
|
||||
self.spawned_at.elapsed().as_secs_f64(),
|
||||
self.raw_output.len(),
|
||||
));
|
||||
self.screen.feed(&chunk);
|
||||
self.timing.feed(&chunk);
|
||||
if self.respond_to_queries {
|
||||
let responses = self.screen.drain_responses();
|
||||
if !responses.is_empty() {
|
||||
let _ = self.pty.inject_keys(&responses);
|
||||
}
|
||||
}
|
||||
PtyPump::Chunk
|
||||
}
|
||||
PtyRead::Timeout => PtyPump::Timeout,
|
||||
PtyRead::Closed => PtyPump::Closed,
|
||||
}
|
||||
}
|
||||
|
||||
/// Feed bytes **directly into the virtual screen only**, bypassing the
|
||||
/// child (grok).
|
||||
///
|
||||
/// Simulates an out-of-band repaint/reflow by an outer layer (tmux, or an
|
||||
/// nvim/vim `:terminal`) that changes what's on screen without going
|
||||
/// through grok's stdout. Used to reproduce the doubled-line class of bugs
|
||||
/// where grok's diff renderer never re-asserts a region it didn't write
|
||||
/// itself (since the harness is a single faithful emulator and cannot nest
|
||||
/// a real tmux/nvim).
|
||||
pub fn feed_screen(&mut self, bytes: &[u8]) {
|
||||
self.screen.feed(bytes);
|
||||
}
|
||||
|
||||
/// Check whether the child process is still running.
|
||||
pub fn is_running(&mut self) -> bool {
|
||||
self.pty.is_running()
|
||||
}
|
||||
|
||||
// ── Screen state queries ─────────────────────────────────────────
|
||||
|
||||
/// Return structured plain-text screen contents.
|
||||
pub fn screen_output(&self) -> ScreenOutput {
|
||||
self.screen.output()
|
||||
}
|
||||
|
||||
/// Return the full text contents of the virtual screen.
|
||||
pub fn screen_contents(&self) -> String {
|
||||
self.screen.contents()
|
||||
}
|
||||
|
||||
/// Return the full screen with style information.
|
||||
pub fn screen_styled(&self) -> Vec<StyledLine> {
|
||||
self.screen.styled()
|
||||
}
|
||||
|
||||
/// Render the current screen as HTML.
|
||||
pub fn screen_html(&self) -> String {
|
||||
self.screen.html()
|
||||
}
|
||||
|
||||
/// Check whether the screen contains the given text.
|
||||
pub fn contains_text(&self, text: &str) -> bool {
|
||||
self.screen.contains(text)
|
||||
}
|
||||
|
||||
/// Pump PTY output until `condition` becomes true or `timeout` expires.
|
||||
///
|
||||
/// The condition is checked before the first pump and after each output
|
||||
/// slice. `description` names the semantic state in timeout diagnostics.
|
||||
pub fn wait_until(
|
||||
&mut self,
|
||||
description: &str,
|
||||
timeout: Duration,
|
||||
mut condition: impl FnMut(&Self) -> bool,
|
||||
) -> Result<()> {
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
if condition(self) {
|
||||
return Ok(());
|
||||
}
|
||||
let remaining = deadline.saturating_duration_since(Instant::now());
|
||||
if remaining.is_zero() {
|
||||
anyhow::bail!(
|
||||
"timed out after {timeout:?} waiting for {description}\n\
|
||||
process running: {}\nscreen contents:\n{}",
|
||||
self.pty.is_running(),
|
||||
self.screen.contents()
|
||||
);
|
||||
}
|
||||
match self.pump_one(Duration::from_millis(50).min(remaining)) {
|
||||
PtyPump::Chunk | PtyPump::Timeout => {}
|
||||
PtyPump::Closed => {
|
||||
anyhow::bail!(
|
||||
"PTY closed while waiting for {description}\n\
|
||||
process running: false\nscreen contents:\n{}\nraw output:\n{}",
|
||||
self.screen.contents(),
|
||||
String::from_utf8_lossy(&self.raw_output)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Like [`Self::wait_until`], but the condition must remain true for `hold`.
|
||||
///
|
||||
/// The single `timeout` covers both reaching the condition and holding it;
|
||||
/// PTY output continues to be pumped throughout the stability window.
|
||||
pub fn wait_until_stable(
|
||||
&mut self,
|
||||
description: &str,
|
||||
timeout: Duration,
|
||||
hold: Duration,
|
||||
mut condition: impl FnMut(&Self) -> bool,
|
||||
) -> Result<()> {
|
||||
let deadline = Instant::now() + timeout;
|
||||
let mut true_since = None;
|
||||
loop {
|
||||
if condition(self) {
|
||||
let since = true_since.get_or_insert_with(Instant::now);
|
||||
if since.elapsed() >= hold {
|
||||
return Ok(());
|
||||
}
|
||||
} else {
|
||||
true_since = None;
|
||||
}
|
||||
|
||||
let remaining = deadline.saturating_duration_since(Instant::now());
|
||||
if remaining.is_zero() {
|
||||
anyhow::bail!(
|
||||
"timed out after {timeout:?} waiting for {description} to remain true for \
|
||||
{hold:?}\nprocess running: {}\nscreen contents:\n{}",
|
||||
self.pty.is_running(),
|
||||
self.screen.contents()
|
||||
);
|
||||
}
|
||||
match self.pump_one(Duration::from_millis(50).min(remaining)) {
|
||||
PtyPump::Chunk | PtyPump::Timeout => {}
|
||||
PtyPump::Closed => {
|
||||
anyhow::bail!(
|
||||
"PTY closed while waiting for {description} to remain true for {hold:?}\n\
|
||||
process running: false\nscreen contents:\n{}\nraw output:\n{}",
|
||||
self.screen.contents(),
|
||||
String::from_utf8_lossy(&self.raw_output)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Block until the screen contains `text` or `timeout` expires.
|
||||
pub fn wait_for_text(&mut self, text: &str, timeout: Duration) -> Result<()> {
|
||||
self.wait_until(&format!("screen text {text:?}"), timeout, |h| {
|
||||
h.contains_text(text)
|
||||
})
|
||||
}
|
||||
|
||||
/// Block until the visible screen no longer contains `text`.
|
||||
pub fn wait_for_text_absent(&mut self, text: &str, timeout: Duration) -> Result<()> {
|
||||
self.wait_until(
|
||||
&format!("screen text {text:?} to disappear"),
|
||||
timeout,
|
||||
|h| !h.contains_text(text),
|
||||
)
|
||||
}
|
||||
|
||||
/// Wait for a rendered response to reach the idle prompt state.
|
||||
///
|
||||
/// Call this after observing turn output: the running status and cancel
|
||||
/// keybar disappear only after the pager finalizes the turn.
|
||||
pub fn wait_for_turn_idle(&mut self, timeout: Duration) -> Result<()> {
|
||||
self.wait_until_stable(
|
||||
"turn to become idle",
|
||||
timeout,
|
||||
Duration::from_millis(250),
|
||||
|h| {
|
||||
!h.contains_text("Ctrl+c:cancel")
|
||||
&& !h.contains_text("Waiting for response")
|
||||
&& !h.contains_text("Responding")
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// Return all raw bytes emitted by the child PTY so far.
|
||||
pub fn raw_output(&self) -> &[u8] {
|
||||
&self.raw_output
|
||||
}
|
||||
|
||||
/// Write everything the child PTY emitted so far as an asciinema v2 cast
|
||||
/// (`.cast`), one output event per received chunk with its original
|
||||
/// arrival timestamp. Replayable locally with `asciinema play`. Bytes are
|
||||
/// decoded lossily so binary escapes cannot poison the JSON encoding.
|
||||
///
|
||||
/// Limitation: the header is pinned to the spawn-time size and no `"r"`
|
||||
/// resize events are emitted, so a cast from a test that calls
|
||||
/// [`resize`](Self::resize) plays back at the original geometry.
|
||||
pub fn write_cast(&self, path: &Path) -> Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.with_context(|| format!("create cast dir {}", parent.display()))?;
|
||||
}
|
||||
let (cols, rows) = self.cast_size;
|
||||
let mut out = String::new();
|
||||
out.push_str(&serde_json::json!({"version": 2, "width": cols, "height": rows}).to_string());
|
||||
out.push('\n');
|
||||
let mut start = 0usize;
|
||||
for (elapsed, end) in &self.cast_events {
|
||||
let mut end = *end;
|
||||
// A multi-byte codepoint split across two PTY reads must not be
|
||||
// lossy-decoded in halves: back off to the char boundary and let
|
||||
// the partial bytes ride in the next event (a dangling tail at
|
||||
// end-of-capture still decodes lossily — nothing to carry into).
|
||||
while end > start
|
||||
&& end < self.raw_output.len()
|
||||
&& (self.raw_output[end] & 0xC0) == 0x80
|
||||
{
|
||||
end -= 1;
|
||||
}
|
||||
if end == start {
|
||||
continue;
|
||||
}
|
||||
let data = String::from_utf8_lossy(&self.raw_output[start..end]);
|
||||
out.push_str(&serde_json::json!([elapsed, "o", data]).to_string());
|
||||
out.push('\n');
|
||||
start = end;
|
||||
}
|
||||
std::fs::write(path, out).with_context(|| format!("write cast {}", path.display()))
|
||||
}
|
||||
|
||||
// ── Scrollback queries (minimal mode commits blocks into native history) ──
|
||||
|
||||
/// The terminal's scrollback history as text (oldest line first).
|
||||
pub fn scrollback_text(&self) -> String {
|
||||
self.screen.scrollback_text()
|
||||
}
|
||||
|
||||
/// Scrollback history + the visible screen, joined oldest→newest. Use for
|
||||
/// minimal-mode assertions: a committed block may be on-screen or scrolled
|
||||
/// above the pinned viewport depending on how much has accumulated.
|
||||
pub fn full_text(&self) -> String {
|
||||
self.screen.full_text()
|
||||
}
|
||||
|
||||
/// Whether scrollback + visible screen contains `text`.
|
||||
pub fn contains_full_text(&self, text: &str) -> bool {
|
||||
self.screen.full_contains(text)
|
||||
}
|
||||
|
||||
/// Block until scrollback + visible screen contains `text`, or `timeout`
|
||||
/// expires. The scrollback-aware companion to [`Self::wait_for_text`] for
|
||||
/// content that may have scrolled above the viewport (minimal mode).
|
||||
pub fn wait_for_full_text(&mut self, text: &str, timeout: Duration) -> Result<()> {
|
||||
let result = self.wait_until(&format!("full text {text:?}"), timeout, |h| {
|
||||
h.contains_full_text(text)
|
||||
});
|
||||
result.map_err(|error| {
|
||||
anyhow::anyhow!("{error}\nfull contents:\n{}", self.screen.full_text())
|
||||
})
|
||||
}
|
||||
|
||||
/// Block until scrollback + visible screen no longer contains `text`.
|
||||
pub fn wait_for_full_text_absent(&mut self, text: &str, timeout: Duration) -> Result<()> {
|
||||
let result = self.wait_until(&format!("full text {text:?} to disappear"), timeout, |h| {
|
||||
!h.contains_full_text(text)
|
||||
});
|
||||
result.map_err(|error| {
|
||||
anyhow::anyhow!("{error}\nfull contents:\n{}", self.screen.full_text())
|
||||
})
|
||||
}
|
||||
|
||||
/// Count Kitty graphics APC sequences that carry image data or placement in
|
||||
/// the raw PTY output so far (delete / capability-query escapes excluded).
|
||||
///
|
||||
/// These escapes are written into the synchronized-update frame buffer
|
||||
/// (outside the vt100 cell grid), so they aren't visible to `wait_for_text`;
|
||||
/// scanning the raw bytes is the only way to observe them.
|
||||
pub fn count_kitty_graphics(&self) -> usize {
|
||||
scripted::count_kitty_graphics(&self.raw_output)
|
||||
}
|
||||
|
||||
/// Block until at least `min` Kitty graphics APC sequences (`ESC _ G`) have
|
||||
/// appeared in the raw PTY output, or `timeout` expires.
|
||||
///
|
||||
/// Polling the raw bytes avoids a fixed sleep (flake under load) and returns
|
||||
/// as soon as the image is transmitted/placed.
|
||||
pub fn wait_for_kitty_graphics(&mut self, min: usize, timeout: Duration) -> Result<()> {
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
if self.count_kitty_graphics() >= min {
|
||||
return Ok(());
|
||||
}
|
||||
let remaining = deadline.saturating_duration_since(Instant::now());
|
||||
if remaining.is_zero() {
|
||||
anyhow::bail!(
|
||||
"timed out after {timeout:?} waiting for {min} Kitty graphics escape(s); \
|
||||
found {}",
|
||||
self.count_kitty_graphics()
|
||||
);
|
||||
}
|
||||
self.update(Duration::from_millis(50).min(remaining));
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the current cursor position as `(row, col)`.
|
||||
pub fn cursor_position(&self) -> (u16, u16) {
|
||||
self.screen.cursor_position()
|
||||
}
|
||||
|
||||
// ── Frame timing queries ─────────────────────────────────────────
|
||||
|
||||
/// Return all recorded frame timings.
|
||||
pub fn frame_timings(&self) -> &[FrameTiming] {
|
||||
self.timing.timings()
|
||||
}
|
||||
|
||||
/// Compute aggregated benchmark results from collected frame timings.
|
||||
pub fn bench_results(&self, scenario: &str, wall_time: Duration) -> BenchResults {
|
||||
BenchResults::from_timings(scenario, self.timing.timings(), wall_time)
|
||||
}
|
||||
|
||||
/// Return the total number of completed frames.
|
||||
pub fn frame_count(&self) -> u64 {
|
||||
self.timing.frame_count()
|
||||
}
|
||||
|
||||
/// Reset all frame timing data.
|
||||
pub fn reset_timing(&mut self) {
|
||||
self.timing.reset();
|
||||
}
|
||||
|
||||
// ── Lifecycle ────────────────────────────────────────────────────
|
||||
|
||||
/// Send 'q' and wait for the child process to exit (5s timeout, then kill).
|
||||
pub fn quit(&mut self) -> Result<()> {
|
||||
self.pty.quit()
|
||||
}
|
||||
|
||||
/// Wait up to `timeout` for the child to exit, returning its exit code
|
||||
/// (`None` if it's still running at the deadline). Call once and cache the
|
||||
/// result — the underlying `try_wait` reaps the child.
|
||||
pub fn wait_exit_code(&mut self, timeout: Duration) -> Option<u32> {
|
||||
self.pty.wait_exit_code(timeout)
|
||||
}
|
||||
|
||||
/// Wait for child exit, then drain final PTY output through EOF or quiet.
|
||||
///
|
||||
/// `exit_timeout` applies only until exit. Once exit is observed, the known
|
||||
/// status is preserved while a separate bounded drain phase runs.
|
||||
pub fn wait_for_exit_and_drain(
|
||||
&mut self,
|
||||
exit_timeout: Duration,
|
||||
drain_timeout: Duration,
|
||||
) -> Result<u32> {
|
||||
let exit_deadline = Instant::now() + exit_timeout;
|
||||
let exit_code = loop {
|
||||
if let Some(code) = self.pty.try_exit_code()? {
|
||||
break code;
|
||||
}
|
||||
let remaining = exit_deadline.saturating_duration_since(Instant::now());
|
||||
if remaining.is_zero() {
|
||||
anyhow::bail!(
|
||||
"timed out after {exit_timeout:?} waiting for child exit\n\
|
||||
process running: true\nscreen contents:\n{}\nraw output:\n{}",
|
||||
self.screen.contents(),
|
||||
String::from_utf8_lossy(&self.raw_output)
|
||||
);
|
||||
}
|
||||
self.update(Duration::from_millis(50).min(remaining));
|
||||
};
|
||||
|
||||
let drain_deadline = Instant::now() + drain_timeout;
|
||||
let mut last_output_at = Instant::now();
|
||||
loop {
|
||||
let remaining = drain_deadline.saturating_duration_since(Instant::now());
|
||||
if remaining.is_zero() {
|
||||
return Ok(exit_code);
|
||||
}
|
||||
match self.pump_one(Duration::from_millis(50).min(remaining)) {
|
||||
PtyPump::Chunk => last_output_at = Instant::now(),
|
||||
PtyPump::Closed => return Ok(exit_code),
|
||||
PtyPump::Timeout if last_output_at.elapsed() >= Duration::from_millis(200) => {
|
||||
return Ok(exit_code);
|
||||
}
|
||||
PtyPump::Timeout => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Child PID (see [`PtyController::child_pid`]).
|
||||
pub fn child_pid(&self) -> Option<u32> {
|
||||
self.pty.child_pid()
|
||||
}
|
||||
|
||||
/// Deliver a signal to the child (unix). See [`PtyController::send_signal`].
|
||||
#[cfg(unix)]
|
||||
pub fn send_signal(&self, signal: i32) -> Result<()> {
|
||||
self.pty.send_signal(signal)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
//! Layer 1: PTY management — spawn, inject keys, resize, drain output.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
use std::path::Path;
|
||||
use std::sync::mpsc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use portable_pty::{CommandBuilder, PtySize, native_pty_system};
|
||||
|
||||
/// Raw key byte constants for terminal input injection.
|
||||
pub mod keys {
|
||||
pub const J: &[u8] = b"j";
|
||||
pub const K: &[u8] = b"k";
|
||||
pub const Q: &[u8] = b"q";
|
||||
pub const DOWN: &[u8] = b"\x1b[B";
|
||||
pub const UP: &[u8] = b"\x1b[A";
|
||||
pub const PGDN: &[u8] = b"\x1b[6~";
|
||||
pub const PGUP: &[u8] = b"\x1b[5~";
|
||||
pub const ENTER: &[u8] = b"\r";
|
||||
pub const CTRL_C: &[u8] = b"\x03";
|
||||
/// Ctrl+R (0x12) — prompt history search / scrollback mouse-reporting toggle.
|
||||
pub const CTRL_R: &[u8] = b"\x12";
|
||||
pub const ESC: &[u8] = b"\x1b";
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum PtyRead {
|
||||
Chunk(Vec<u8>),
|
||||
Timeout,
|
||||
Closed,
|
||||
}
|
||||
|
||||
/// Low-level PTY controller: spawns a child process inside a PTY and provides
|
||||
/// methods to inject input, resize, and drain output.
|
||||
pub struct PtyController {
|
||||
child: Box<dyn portable_pty::Child + Send>,
|
||||
writer: Box<dyn Write + Send>,
|
||||
reader_rx: mpsc::Receiver<Vec<u8>>,
|
||||
#[allow(dead_code)] // Kept alive to hold the PTY open; used by resize().
|
||||
master: Box<dyn portable_pty::MasterPty + Send>,
|
||||
}
|
||||
|
||||
impl PtyController {
|
||||
/// Spawn a binary inside a PTY with the given terminal size.
|
||||
///
|
||||
/// `env` is a list of `(key, value)` pairs to set on the child process.
|
||||
pub fn spawn(
|
||||
binary: &Path,
|
||||
size: PtySize,
|
||||
args: &[&str],
|
||||
env: &[(&str, &str)],
|
||||
) -> Result<Self> {
|
||||
Self::spawn_in_dir(binary, size, args, env, None)
|
||||
}
|
||||
|
||||
/// Like [`spawn`](Self::spawn), with an optional child working directory.
|
||||
pub fn spawn_in_dir(
|
||||
binary: &Path,
|
||||
size: PtySize,
|
||||
args: &[&str],
|
||||
env: &[(&str, &str)],
|
||||
cwd: Option<&Path>,
|
||||
) -> Result<Self> {
|
||||
let pty_system = native_pty_system();
|
||||
let pair = pty_system.openpty(size)?;
|
||||
|
||||
let mut cmd = CommandBuilder::new(binary);
|
||||
for arg in args {
|
||||
cmd.arg(*arg);
|
||||
}
|
||||
if let Some(dir) = cwd {
|
||||
cmd.cwd(dir);
|
||||
}
|
||||
apply_child_env(&mut cmd, env);
|
||||
|
||||
let child = pair.slave.spawn_command(cmd)?;
|
||||
// Drop the slave so we get EOF when the child exits.
|
||||
drop(pair.slave);
|
||||
|
||||
let reader = pair.master.try_clone_reader()?;
|
||||
let writer = pair.master.take_writer()?;
|
||||
let reader_rx = spawn_reader(reader);
|
||||
|
||||
Ok(Self {
|
||||
child,
|
||||
writer,
|
||||
reader_rx,
|
||||
master: pair.master,
|
||||
})
|
||||
}
|
||||
|
||||
/// Write raw key bytes into the PTY stdin.
|
||||
pub fn inject_keys(&mut self, keys: &[u8]) -> Result<()> {
|
||||
self.writer
|
||||
.write_all(keys)
|
||||
.context("failed to write to PTY stdin")
|
||||
}
|
||||
|
||||
/// Resize the PTY (sends SIGWINCH to the child). Arguments are `(rows, cols)`.
|
||||
pub fn resize(&self, rows: u16, cols: u16) -> Result<()> {
|
||||
self.master
|
||||
.resize(PtySize {
|
||||
rows,
|
||||
cols,
|
||||
pixel_width: 0,
|
||||
pixel_height: 0,
|
||||
})
|
||||
.context("failed to resize PTY")
|
||||
}
|
||||
|
||||
/// Drain the reader channel, collecting all available chunks within `timeout`.
|
||||
pub fn drain_output(&self, timeout: Duration) -> Vec<Vec<u8>> {
|
||||
let mut chunks = Vec::new();
|
||||
let deadline = std::time::Instant::now() + timeout;
|
||||
|
||||
loop {
|
||||
let remaining = deadline.saturating_duration_since(std::time::Instant::now());
|
||||
if remaining.is_zero() {
|
||||
break;
|
||||
}
|
||||
match self.reader_rx.recv_timeout(remaining) {
|
||||
Ok(chunk) => chunks.push(chunk),
|
||||
Err(mpsc::RecvTimeoutError::Timeout) => break,
|
||||
Err(mpsc::RecvTimeoutError::Disconnected) => break,
|
||||
}
|
||||
}
|
||||
|
||||
chunks
|
||||
}
|
||||
|
||||
/// Send 'q' to trigger the pager's quit handler and wait for exit.
|
||||
///
|
||||
/// Key injection is best-effort — the child may have already exited
|
||||
/// (e.g. no ACP server), so write failures are silently ignored.
|
||||
pub fn quit(&mut self) -> Result<()> {
|
||||
let _ = self.inject_keys(keys::Q);
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(5);
|
||||
loop {
|
||||
match self.child.try_wait()? {
|
||||
Some(_) => return Ok(()),
|
||||
None if std::time::Instant::now() >= deadline => {
|
||||
self.child.kill()?;
|
||||
self.child
|
||||
.wait()
|
||||
.context("failed to wait for pager child after kill")?;
|
||||
return Ok(());
|
||||
}
|
||||
None => std::thread::sleep(Duration::from_millis(50)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Receive one chunk, distinguishing timeout from reader EOF.
|
||||
///
|
||||
/// Processing each chunk inline preserves inter-chunk timing.
|
||||
pub(crate) fn recv_chunk(&self, timeout: Duration) -> PtyRead {
|
||||
match self.reader_rx.recv_timeout(timeout) {
|
||||
Ok(chunk) => PtyRead::Chunk(chunk),
|
||||
Err(mpsc::RecvTimeoutError::Timeout) => PtyRead::Timeout,
|
||||
Err(mpsc::RecvTimeoutError::Disconnected) => PtyRead::Closed,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check whether the child process is still running.
|
||||
pub fn is_running(&mut self) -> bool {
|
||||
matches!(self.child.try_wait(), Ok(None))
|
||||
}
|
||||
|
||||
/// Poll child status once, preserving process-query errors.
|
||||
pub(crate) fn try_exit_code(&mut self) -> Result<Option<u32>> {
|
||||
self.child
|
||||
.try_wait()
|
||||
.map(|status| status.map(|status| status.exit_code()))
|
||||
.context("failed to query PTY child status")
|
||||
}
|
||||
|
||||
/// Wait up to `timeout` for the child to exit, returning its exit code
|
||||
/// (`None` if it's still running at the deadline). Call once and cache the
|
||||
/// result — `try_wait` reaps the child, so the status isn't re-readable.
|
||||
pub fn wait_exit_code(&mut self, timeout: Duration) -> Option<u32> {
|
||||
let deadline = std::time::Instant::now() + timeout;
|
||||
loop {
|
||||
match self.child.try_wait() {
|
||||
Ok(Some(status)) => return Some(status.exit_code()),
|
||||
Ok(None) if std::time::Instant::now() >= deadline => return None,
|
||||
Ok(None) => std::thread::sleep(Duration::from_millis(50)),
|
||||
Err(_) => return None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Child PID, falling back to the PTY's foreground process group.
|
||||
#[cfg(unix)]
|
||||
pub fn child_pid(&self) -> Option<u32> {
|
||||
self.child
|
||||
.process_id()
|
||||
.or_else(|| self.master.process_group_leader().map(|p| p as u32))
|
||||
}
|
||||
|
||||
/// Child PID (no foreground-group fallback — ConPTY has no process groups).
|
||||
#[cfg(windows)]
|
||||
pub fn child_pid(&self) -> Option<u32> {
|
||||
self.child.process_id()
|
||||
}
|
||||
|
||||
/// Deliver a signal directly to the child (unix), bypassing the PTY line
|
||||
/// discipline. Exercises the real SIGINT/SIGTERM/SIGHUP paths (distinct from
|
||||
/// injected Ctrl+C key bytes, which are key events under raw mode).
|
||||
/// Call before the child is reaped: a reaped pid can be reused.
|
||||
#[cfg(unix)]
|
||||
pub fn send_signal(&self, signal: i32) -> Result<()> {
|
||||
let pid = self.child_pid().context("no child pid to signal")?;
|
||||
// SAFETY: libc::kill has no memory-safety preconditions, and child_pid()
|
||||
// only yields a positive live-child pid (never the kill(0)/kill(-1) broadcast).
|
||||
let rc = unsafe { libc::kill(pid as libc::pid_t, signal) };
|
||||
if rc != 0 {
|
||||
return Err(anyhow::Error::from(std::io::Error::last_os_error())).context("libc::kill");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for PtyController {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.child.kill();
|
||||
let _ = self.child.wait();
|
||||
}
|
||||
}
|
||||
|
||||
/// Host terminal identity markers stripped from the child environment.
|
||||
///
|
||||
/// The pager's terminal detection
|
||||
/// (`kigi-pager-render/src/terminal/mod.rs`:
|
||||
/// `detect_terminal_brand_from_env` / `detect_byobu_from_env` /
|
||||
/// `detect_multiplexer_from_env` / `detect_tmux_meta_from_env`, plus
|
||||
/// `embedded_editor.rs`'s `embedded_editor_from_env`) reads all of these,
|
||||
/// so any one leaking from the harness's own host terminal reclassifies the
|
||||
/// child: a dev running tests inside tmux leaks `TMUX` (every cell becomes
|
||||
/// the remuxed profile), inside Cursor leaks `CURSOR_TRACE_ID` (checked
|
||||
/// *before* `TERM_PROGRAM`, so it overrides even a test-injected brand),
|
||||
/// inside nvim's `:terminal` leaks `NVIM` (clipboard OSC 52 wrapping).
|
||||
/// Keep this list in sync with the detection source above.
|
||||
const HOST_TERMINAL_ENV_VARS: &[&str] = &[
|
||||
// Brand chain (detect_terminal_brand_from_env), in detection order.
|
||||
"CURSOR_TRACE_ID",
|
||||
"VSCODE_GIT_ASKPASS_MAIN",
|
||||
"TERM_PROGRAM",
|
||||
"TERM_PROGRAM_VERSION",
|
||||
"TERMINAL_EMULATOR",
|
||||
"WEZTERM_VERSION",
|
||||
"ITERM_SESSION_ID",
|
||||
"ITERM_PROFILE",
|
||||
"LC_TERMINAL",
|
||||
"LC_TERMINAL_VERSION",
|
||||
"TERM_SESSION_ID",
|
||||
"KITTY_WINDOW_ID",
|
||||
"ALACRITTY_SOCKET",
|
||||
"TERMINATOR_UUID",
|
||||
"VTE_VERSION",
|
||||
"WT_SESSION",
|
||||
// Multiplexer / Byobu markers (detect_multiplexer_from_env,
|
||||
// detect_byobu_from_env, detect_tmux_meta_from_env).
|
||||
"TMUX",
|
||||
"TMUX_PANE",
|
||||
"ZELLIJ",
|
||||
"ZELLIJ_SESSION_NAME",
|
||||
"STY",
|
||||
"BYOBU_BACKEND",
|
||||
"BYOBU_CONFIG_DIR",
|
||||
"BYOBU_DISTRO",
|
||||
"CMUX_SOCKET_PATH",
|
||||
"CMUX_PANEL_ID",
|
||||
"CMUX_BUNDLE_ID",
|
||||
// Embedded editor markers (embedded_editor_from_env).
|
||||
"NVIM",
|
||||
"NVIM_LISTEN_ADDRESS",
|
||||
"VIM_TERMINAL",
|
||||
"INSIDE_EMACS",
|
||||
];
|
||||
|
||||
/// Prepare the child environment: fixed `TERM`, color and host-terminal
|
||||
/// hygiene strips, then the caller's `env` pairs.
|
||||
///
|
||||
/// Strips run BEFORE the caller env is applied, preserving the contract
|
||||
/// that tests may re-inject any marker (e.g. `TERM_PROGRAM=vscode`, or a
|
||||
/// fake `NVIM` socket) to simulate that host — see
|
||||
/// `tests/pty_e2e/doubled_lines_out_of_band_repro.rs` in the pager crate.
|
||||
fn apply_child_env(cmd: &mut CommandBuilder, env: &[(&str, &str)]) {
|
||||
// Set TERM so the pager renders with full color support.
|
||||
cmd.env("TERM", "xterm-256color");
|
||||
// Strip inherited color opt-outs/overrides for the same reason: a
|
||||
// leaked NO_COLOR (common in agent/CI shells) renders the pager
|
||||
// colorless, making style-sensitive assertions (e.g. the selection
|
||||
// highlight color swap) silently untestable on some hosts. Tests may
|
||||
// re-set these via the `env` list (applied after this).
|
||||
for color_var in ["NO_COLOR", "CLICOLOR", "CLICOLOR_FORCE"] {
|
||||
cmd.env_remove(color_var);
|
||||
}
|
||||
// Strip SSH vars inherited from the parent: the harness PTY is a
|
||||
// local terminal, but `SSH_CONNECTION`/`SSH_TTY` leaking through
|
||||
// makes the pager's terminal detector report SSH and disable the
|
||||
// drag-drop image classifier (see
|
||||
// `try_handle_dropped_paths_paste` in `agent_view.rs`).
|
||||
for ssh_var in ["SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY", "SSH_AUTH_SOCK"] {
|
||||
cmd.env_remove(ssh_var);
|
||||
}
|
||||
// Neutralize parent-terminal identity bleed: agent hosts often export
|
||||
// TERM_PROGRAM=ghostty/iTerm/etc. (and mux/editor markers) which make
|
||||
// the child pager adopt that host's key/modifier/clipboard quirks even
|
||||
// though we only set TERM above.
|
||||
for term_var in HOST_TERMINAL_ENV_VARS {
|
||||
cmd.env_remove(term_var);
|
||||
}
|
||||
for &(key, val) in env {
|
||||
cmd.env(key, val);
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn a background thread that reads from the PTY master and sends
|
||||
/// chunks over an `mpsc` channel. The reader is blocking (WezTerm pattern),
|
||||
/// so it must live on its own thread.
|
||||
fn spawn_reader(mut reader: Box<dyn Read + Send>) -> mpsc::Receiver<Vec<u8>> {
|
||||
let (tx, rx) = mpsc::channel();
|
||||
std::thread::Builder::new()
|
||||
.name("pty-reader".into())
|
||||
.spawn(move || {
|
||||
let mut buf = [0u8; 8192];
|
||||
loop {
|
||||
match reader.read(&mut buf) {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(n) => {
|
||||
if tx.send(buf[..n].to_vec()).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.expect("failed to spawn pty-reader thread");
|
||||
rx
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Every host-terminal marker the pager's detection chain reads must be
|
||||
/// stripped from the child env — polluted entries are seeded via
|
||||
/// `cmd.env` (same `CommandBuilder` map that inherited base-env entries
|
||||
/// live in, so `env_remove` takes the identical path) rather than
|
||||
/// process-global `set_var` (racy under parallel tests).
|
||||
#[test]
|
||||
fn apply_child_env_strips_all_host_terminal_markers() {
|
||||
let mut cmd = CommandBuilder::new("true");
|
||||
for var in HOST_TERMINAL_ENV_VARS {
|
||||
cmd.env(var, "polluted");
|
||||
}
|
||||
for ssh_var in ["SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY", "SSH_AUTH_SOCK"] {
|
||||
cmd.env(ssh_var, "polluted");
|
||||
}
|
||||
for color_var in ["NO_COLOR", "CLICOLOR", "CLICOLOR_FORCE"] {
|
||||
cmd.env(color_var, "polluted");
|
||||
}
|
||||
// Unrelated vars must survive the hygiene pass untouched.
|
||||
cmd.env("KIGI_SCROLL_LOG", "/tmp/scroll.jsonl");
|
||||
|
||||
apply_child_env(&mut cmd, &[]);
|
||||
|
||||
for var in HOST_TERMINAL_ENV_VARS {
|
||||
assert!(
|
||||
cmd.get_env(var).is_none(),
|
||||
"host terminal marker {var} leaked into the child env"
|
||||
);
|
||||
}
|
||||
for ssh_var in ["SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY", "SSH_AUTH_SOCK"] {
|
||||
assert!(
|
||||
cmd.get_env(ssh_var).is_none(),
|
||||
"SSH marker {ssh_var} leaked into the child env"
|
||||
);
|
||||
}
|
||||
for color_var in ["NO_COLOR", "CLICOLOR", "CLICOLOR_FORCE"] {
|
||||
assert!(
|
||||
cmd.get_env(color_var).is_none(),
|
||||
"color override {color_var} leaked into the child env"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
cmd.get_env("TERM").and_then(|v| v.to_str()),
|
||||
Some("xterm-256color")
|
||||
);
|
||||
assert_eq!(
|
||||
cmd.get_env("KIGI_SCROLL_LOG").and_then(|v| v.to_str()),
|
||||
Some("/tmp/scroll.jsonl"),
|
||||
"hygiene must not touch unrelated vars"
|
||||
);
|
||||
}
|
||||
|
||||
/// The documented override contract: strips run BEFORE the caller env,
|
||||
/// so tests can re-inject any marker to simulate a specific host
|
||||
/// (e.g. the fake-nvim wrapper repro or the xtversion brand fixtures).
|
||||
#[test]
|
||||
fn apply_child_env_caller_env_overrides_survive_strips() {
|
||||
let mut cmd = CommandBuilder::new("true");
|
||||
cmd.env("TMUX", "/tmp/host-tmux,999,0");
|
||||
cmd.env("CURSOR_TRACE_ID", "host-cursor");
|
||||
|
||||
apply_child_env(
|
||||
&mut cmd,
|
||||
&[
|
||||
("TERM_PROGRAM", "vscode"),
|
||||
("NVIM", "/tmp/fake-nvim.sock"),
|
||||
("TERM", "xterm-kitty"),
|
||||
],
|
||||
);
|
||||
|
||||
// Host pollution is gone…
|
||||
assert!(cmd.get_env("TMUX").is_none());
|
||||
assert!(cmd.get_env("CURSOR_TRACE_ID").is_none());
|
||||
// …but caller-injected markers (applied after strips) survive,
|
||||
// including overriding the harness's own TERM default.
|
||||
assert_eq!(
|
||||
cmd.get_env("TERM_PROGRAM").and_then(|v| v.to_str()),
|
||||
Some("vscode")
|
||||
);
|
||||
assert_eq!(
|
||||
cmd.get_env("NVIM").and_then(|v| v.to_str()),
|
||||
Some("/tmp/fake-nvim.sock")
|
||||
);
|
||||
assert_eq!(
|
||||
cmd.get_env("TERM").and_then(|v| v.to_str()),
|
||||
Some("xterm-kitty")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
//! Aggregated benchmark results, percentile computation, baseline compare.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::timing::FrameTiming;
|
||||
|
||||
/// Aggregated benchmark results for a single scenario run.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BenchResults {
|
||||
pub scenario: String,
|
||||
pub total_frames: u64,
|
||||
pub avg_fps: f64,
|
||||
pub p50_ms: f64,
|
||||
pub p99_ms: f64,
|
||||
pub max_ms: f64,
|
||||
pub jank_count: u64,
|
||||
pub jank_rate: f64,
|
||||
pub chars_per_frame_avg: f64,
|
||||
}
|
||||
|
||||
impl BenchResults {
|
||||
/// Compute aggregate statistics from per-frame timings.
|
||||
///
|
||||
/// `wall_time` is the total elapsed time during which frames were collected,
|
||||
/// used to compute `avg_fps`. This should be measured by the caller.
|
||||
pub fn from_timings(scenario: &str, timings: &[FrameTiming], wall_time: Duration) -> Self {
|
||||
let total_frames = timings.len() as u64;
|
||||
|
||||
if timings.is_empty() {
|
||||
return Self {
|
||||
scenario: scenario.to_owned(),
|
||||
total_frames: 0,
|
||||
avg_fps: 0.0,
|
||||
p50_ms: 0.0,
|
||||
p99_ms: 0.0,
|
||||
max_ms: 0.0,
|
||||
jank_count: 0,
|
||||
jank_rate: 0.0,
|
||||
chars_per_frame_avg: 0.0,
|
||||
};
|
||||
}
|
||||
|
||||
let mut durations_ms: Vec<f64> = timings
|
||||
.iter()
|
||||
.map(|t| t.duration.as_secs_f64() * 1000.0)
|
||||
.collect();
|
||||
durations_ms.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
||||
|
||||
let wall_secs = wall_time.as_secs_f64();
|
||||
let avg_fps = if wall_secs > 0.0 {
|
||||
total_frames as f64 / wall_secs
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let p50_ms = percentile(&durations_ms, 50.0);
|
||||
let p99_ms = percentile(&durations_ms, 99.0);
|
||||
let max_ms = durations_ms.last().copied().unwrap_or(0.0);
|
||||
|
||||
// Jank threshold: frame time > 2x median (p50).
|
||||
let jank_threshold = p50_ms * 2.0;
|
||||
let jank_count = durations_ms.iter().filter(|&&d| d > jank_threshold).count() as u64;
|
||||
let jank_rate = jank_count as f64 / total_frames as f64;
|
||||
|
||||
let total_chars: usize = timings.iter().map(|t| t.chars).sum();
|
||||
let chars_per_frame_avg = total_chars as f64 / total_frames as f64;
|
||||
|
||||
Self {
|
||||
scenario: scenario.to_owned(),
|
||||
total_frames,
|
||||
avg_fps,
|
||||
p50_ms,
|
||||
p99_ms,
|
||||
max_ms,
|
||||
jank_count,
|
||||
jank_rate,
|
||||
chars_per_frame_avg,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Baseline comparison ────────────────────────────────────────────────────
|
||||
|
||||
/// Regression threshold: fail if a scenario's p99 frame time grows by more
|
||||
/// than this fraction (0.15 = 15%). Matches the RFC's proposal.
|
||||
pub const DEFAULT_REGRESSION_THRESHOLD: f64 = 0.15;
|
||||
|
||||
/// On-disk baseline schema: `{ "<scenario>": BenchResults, ... }`.
|
||||
pub type Baseline = HashMap<String, BenchResults>;
|
||||
|
||||
/// Load a baseline file from disk.
|
||||
pub fn load_baseline(path: &Path) -> Result<Baseline> {
|
||||
let text = std::fs::read_to_string(path)
|
||||
.with_context(|| format!("read baseline file {}", path.display()))?;
|
||||
serde_json::from_str(&text).with_context(|| format!("parse baseline file {}", path.display()))
|
||||
}
|
||||
|
||||
/// Persist a set of results as a baseline file (overwrites if present).
|
||||
pub fn write_baseline(path: &Path, results: &[BenchResults]) -> Result<()> {
|
||||
let map: Baseline = results
|
||||
.iter()
|
||||
.map(|r| (r.scenario.clone(), r.clone()))
|
||||
.collect();
|
||||
let json = serde_json::to_string_pretty(&map).context("serialize baseline")?;
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.with_context(|| format!("create baseline parent {}", parent.display()))?;
|
||||
}
|
||||
std::fs::write(path, json)
|
||||
.with_context(|| format!("write baseline file {}", path.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Outcome of comparing a single scenario's current run against its baseline.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ScenarioRegression {
|
||||
pub scenario: String,
|
||||
pub baseline_p99_ms: f64,
|
||||
pub current_p99_ms: f64,
|
||||
pub pct_delta: f64,
|
||||
}
|
||||
|
||||
/// Compare the given `results` against `baseline`, returning every scenario
|
||||
/// whose p99 grew by more than `threshold` (as a fraction, e.g. 0.15 = 15%).
|
||||
///
|
||||
/// Scenarios missing from the baseline are skipped (first run of a new
|
||||
/// scenario is not a regression).
|
||||
pub fn compare_baseline(
|
||||
results: &[BenchResults],
|
||||
baseline: &Baseline,
|
||||
threshold: f64,
|
||||
) -> Vec<ScenarioRegression> {
|
||||
let mut regressions = Vec::new();
|
||||
for r in results {
|
||||
let Some(b) = baseline.get(&r.scenario) else {
|
||||
continue;
|
||||
};
|
||||
if b.p99_ms <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let pct_delta = (r.p99_ms - b.p99_ms) / b.p99_ms;
|
||||
if pct_delta > threshold {
|
||||
regressions.push(ScenarioRegression {
|
||||
scenario: r.scenario.clone(),
|
||||
baseline_p99_ms: b.p99_ms,
|
||||
current_p99_ms: r.p99_ms,
|
||||
pct_delta,
|
||||
});
|
||||
}
|
||||
}
|
||||
regressions
|
||||
}
|
||||
|
||||
/// Compute the `pct`-th percentile from a **sorted** slice of values.
|
||||
///
|
||||
/// `pct` must be in `[0.0, 100.0]`. The input slice must be sorted in
|
||||
/// ascending order; this is enforced by debug assertion.
|
||||
pub fn percentile(sorted: &[f64], pct: f64) -> f64 {
|
||||
debug_assert!(
|
||||
(0.0..=100.0).contains(&pct),
|
||||
"percentile must be in [0.0, 100.0], got {pct}"
|
||||
);
|
||||
debug_assert!(
|
||||
sorted.windows(2).all(|w| w[0] <= w[1]),
|
||||
"input must be sorted"
|
||||
);
|
||||
if sorted.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
let idx = (pct / 100.0 * (sorted.len() - 1) as f64).round() as usize;
|
||||
sorted[idx.min(sorted.len() - 1)]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn empty_timings_returns_zeroed() {
|
||||
let results = BenchResults::from_timings("empty", &[], Duration::from_secs(1));
|
||||
assert_eq!(results.total_frames, 0);
|
||||
assert_eq!(results.avg_fps, 0.0);
|
||||
assert_eq!(results.p50_ms, 0.0);
|
||||
assert_eq!(results.p99_ms, 0.0);
|
||||
assert_eq!(results.max_ms, 0.0);
|
||||
assert_eq!(results.jank_count, 0);
|
||||
assert_eq!(results.chars_per_frame_avg, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_frame_returns_correct_values() {
|
||||
let timings = vec![FrameTiming {
|
||||
duration: Duration::from_millis(16),
|
||||
chars: 100,
|
||||
}];
|
||||
let results = BenchResults::from_timings("single", &timings, Duration::from_secs(1));
|
||||
assert_eq!(results.total_frames, 1);
|
||||
assert!((results.avg_fps - 1.0).abs() < 0.01);
|
||||
assert!((results.p50_ms - 16.0).abs() < 0.1);
|
||||
assert!((results.p99_ms - 16.0).abs() < 0.1);
|
||||
assert!((results.max_ms - 16.0).abs() < 0.1);
|
||||
assert_eq!(results.jank_count, 0);
|
||||
assert!((results.chars_per_frame_avg - 100.0).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_frames_statistics() {
|
||||
let timings: Vec<FrameTiming> = (0..100)
|
||||
.map(|i| FrameTiming {
|
||||
duration: Duration::from_millis(10 + i % 5),
|
||||
chars: 50,
|
||||
})
|
||||
.collect();
|
||||
let results = BenchResults::from_timings("multi", &timings, Duration::from_secs(2));
|
||||
assert_eq!(results.total_frames, 100);
|
||||
assert!((results.avg_fps - 50.0).abs() < 0.01);
|
||||
assert!(results.p50_ms >= 10.0 && results.p50_ms <= 14.0);
|
||||
assert!(results.p99_ms >= 10.0 && results.p99_ms <= 14.0);
|
||||
assert!((results.max_ms - 14.0).abs() < 0.1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn percentile_empty_returns_zero() {
|
||||
assert_eq!(percentile(&[], 50.0), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn percentile_single_element() {
|
||||
assert_eq!(percentile(&[42.0], 50.0), 42.0);
|
||||
assert_eq!(percentile(&[42.0], 0.0), 42.0);
|
||||
assert_eq!(percentile(&[42.0], 100.0), 42.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn percentile_multiple_elements() {
|
||||
let sorted: Vec<f64> = (1..=100).map(|i| i as f64).collect();
|
||||
// p50 of 1..=100 should be around 50
|
||||
let p50 = percentile(&sorted, 50.0);
|
||||
assert!((p50 - 50.0).abs() < 1.1);
|
||||
// p99 should be around 99
|
||||
let p99 = percentile(&sorted, 99.0);
|
||||
assert!((p99 - 99.0).abs() < 1.1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jank_detection() {
|
||||
// 9 frames at 10ms, 1 frame at 50ms (>2x median = jank)
|
||||
let mut timings: Vec<FrameTiming> = (0..9)
|
||||
.map(|_| FrameTiming {
|
||||
duration: Duration::from_millis(10),
|
||||
chars: 10,
|
||||
})
|
||||
.collect();
|
||||
timings.push(FrameTiming {
|
||||
duration: Duration::from_millis(50),
|
||||
chars: 10,
|
||||
});
|
||||
let results = BenchResults::from_timings("jank", &timings, Duration::from_secs(1));
|
||||
assert_eq!(results.jank_count, 1);
|
||||
assert!((results.jank_rate - 0.1).abs() < 0.01);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
//! Empty-composer Enter sends the top mid-turn queued follow-up now.
|
||||
//!
|
||||
//! Regression for send-now discoverability: plain Enter with text still
|
||||
//! *queues*; a second bare Enter on the empty prompt is cancel-and-send — the
|
||||
//! running turn is cancelled (silently: no "Turn cancelled by user" marker)
|
||||
//! and the queued row runs as the next turn, arriving on the wire as a
|
||||
//! standard `<user_query>` prompt with no interjection preamble.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
|
||||
use super::wait_for_welcome;
|
||||
use crate::{ContentController, PtyHarness, pager_binary};
|
||||
|
||||
const DEFAULT_ROWS: u16 = 50;
|
||||
const DEFAULT_COLS: u16 = 120;
|
||||
/// The interjection-merge preamble: send-now must never produce it.
|
||||
const INTERJECTION_WIRE_PREFIX: &str = "The user sent a message while you were working";
|
||||
|
||||
fn slow_turn_text(sentinel: &str) -> String {
|
||||
let mut s = String::from(sentinel);
|
||||
for i in 0..30 {
|
||||
s.push_str(&format!(" streaming{i}"));
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn all_user_message_blobs(content: &ContentController) -> Vec<String> {
|
||||
content
|
||||
.request_bodies()
|
||||
.iter()
|
||||
.flat_map(|b| {
|
||||
let items = b["messages"].as_array().or_else(|| b["input"].as_array());
|
||||
items
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter(|m| m["role"] == "user")
|
||||
.map(|m| match m["content"].as_str() {
|
||||
Some(s) => s.to_owned(),
|
||||
None => m["content"].to_string(),
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Mid-turn queue via Enter, then empty Enter cancels the running turn and
|
||||
/// runs that row as the next turn (cancel-and-send).
|
||||
pub async fn assert_empty_enter_force_sends_top_queued() -> Result<()> {
|
||||
let content = ContentController::start()
|
||||
.await
|
||||
.context("start ContentController")?;
|
||||
// Gate turn 1's terminal event so the queue + empty-Enter provably land
|
||||
// mid-turn — a paced-chunk window races turn end on slow (remote) workers.
|
||||
content.hold_agent_completions();
|
||||
content.set_turns([
|
||||
slow_turn_text("TURNONE"),
|
||||
"TURNTWO reply to the promoted follow-up.".to_owned(),
|
||||
]);
|
||||
|
||||
let binary = pager_binary().context("resolve pager binary")?;
|
||||
let mut harness =
|
||||
PtyHarness::spawn_with_content(&binary, DEFAULT_ROWS, DEFAULT_COLS, &content, &[])
|
||||
.context("spawn pager")?;
|
||||
|
||||
wait_for_welcome(&mut harness).await?;
|
||||
|
||||
harness.inject_keys(b"go\r").context("submit prompt")?;
|
||||
harness
|
||||
.wait_for_text("TURNONE", Duration::from_secs(30))
|
||||
.context("turn 1 streaming")?;
|
||||
|
||||
harness
|
||||
.inject_keys(b"please also check the logs\r")
|
||||
.context("queue follow-up")?;
|
||||
harness
|
||||
.wait_for_text("please also check the logs", Duration::from_secs(10))
|
||||
.context("queued text visible")?;
|
||||
|
||||
harness.inject_keys(b"\r").context("empty Enter send-now")?;
|
||||
// Cancel-and-send: the shell cancels turn 1 (its held completion is
|
||||
// irrelevant — the abort wins) and promotes the row to run as turn 2.
|
||||
// Release the gate so any completion race resolves rather than hangs.
|
||||
content.release_agent_completions();
|
||||
// The promoted row renders as a standard user prompt block ("❯ " prefix
|
||||
// distinguishes the committed block from the prefix-less queue row) with
|
||||
// the new turn's reply below it.
|
||||
harness
|
||||
.wait_for_text(
|
||||
"\u{276F} please also check the logs",
|
||||
Duration::from_secs(15),
|
||||
)
|
||||
.context("promoted prompt scrollback chrome")?;
|
||||
harness
|
||||
.wait_for_text("TURNTWO", Duration::from_secs(40))
|
||||
.context("promoted turn reply")?;
|
||||
|
||||
// A send-now cancel is silent: no "Turn cancelled by user" marker may
|
||||
// appear between the partial turn-1 output and the promoted prompt.
|
||||
if harness.contains_text("Turn cancelled by user") {
|
||||
bail!(
|
||||
"send-now cancel must not render a cancelled marker\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
}
|
||||
|
||||
let users = all_user_message_blobs(&content);
|
||||
let Some(promoted) = users
|
||||
.iter()
|
||||
.find(|u| u.contains("please also check the logs"))
|
||||
else {
|
||||
bail!("queued follow-up never reached the wire: {users:#?}");
|
||||
};
|
||||
if promoted.contains(INTERJECTION_WIRE_PREFIX) {
|
||||
bail!("send-now must not use the interjection preamble: {promoted}");
|
||||
}
|
||||
if !promoted.contains("<user_query>") {
|
||||
bail!("send-now must arrive as a standard user_query prompt: {promoted}");
|
||||
}
|
||||
if harness.contains_text("panicked") {
|
||||
bail!("pager panicked\n{}", harness.screen_contents());
|
||||
}
|
||||
|
||||
harness.quit().context("clean quit")?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
//! `idle_cost` — after content settles, measure frames for N seconds of
|
||||
//! true idle. Catches the `needs_animation()` always-true bug: frames > 0
|
||||
//! here means the pager is ticking when it shouldn't.
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use super::{BenchResults, ContentController, PtyHarness, wait_for_welcome};
|
||||
|
||||
const IDLE_WINDOW: Duration = Duration::from_secs(3);
|
||||
|
||||
pub async fn run(harness: &mut PtyHarness, _content: &ContentController) -> Result<BenchResults> {
|
||||
wait_for_welcome(harness).await?;
|
||||
// Let the splash screen animation settle (some of the pager's intro
|
||||
// screens do a brief fade/animation).
|
||||
harness.update(Duration::from_secs(1));
|
||||
harness.reset_timing();
|
||||
|
||||
let start = Instant::now();
|
||||
while start.elapsed() < IDLE_WINDOW {
|
||||
harness.update(Duration::from_millis(100));
|
||||
if !harness.is_running() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let wall_time = start.elapsed();
|
||||
|
||||
Ok(harness.bench_results("idle_cost", wall_time))
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
//! `large_codeblock` — render a large syntax-highlighted Rust code block
|
||||
//! and scroll through it.
|
||||
//!
|
||||
//! What it stresses: `syntect` highlighting cache, wrapping of long source
|
||||
//! lines, ScratchBuffer copy for a single oversized entry.
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use super::{BenchResults, ContentController, PtyHarness, wait_for_welcome};
|
||||
use crate::keys;
|
||||
|
||||
const LINES: usize = 400;
|
||||
const SCROLL_KEYS: usize = 120;
|
||||
const KEY_INTERVAL: Duration = Duration::from_millis(20);
|
||||
|
||||
pub async fn run(harness: &mut PtyHarness, content: &ContentController) -> Result<BenchResults> {
|
||||
content.set_response(build_rust_codeblock(LINES));
|
||||
|
||||
wait_for_welcome(harness).await?;
|
||||
harness.inject_keys(b"code\r")?;
|
||||
harness.wait_for_text("fn code_sample", Duration::from_secs(30))?;
|
||||
harness.update(Duration::from_millis(500));
|
||||
harness.reset_timing();
|
||||
|
||||
let start = Instant::now();
|
||||
for _ in 0..SCROLL_KEYS {
|
||||
harness.inject_keys(keys::J)?;
|
||||
harness.update(KEY_INTERVAL);
|
||||
if !harness.is_running() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
harness.update(Duration::from_millis(250));
|
||||
let wall_time = start.elapsed();
|
||||
|
||||
Ok(harness.bench_results("large_codeblock", wall_time))
|
||||
}
|
||||
|
||||
fn build_rust_codeblock(lines: usize) -> String {
|
||||
let mut s = String::with_capacity(lines * 60);
|
||||
s.push_str("```rust\n");
|
||||
for i in 0..lines {
|
||||
s.push_str(&format!(
|
||||
"fn code_sample_{i}(x: i32) -> i32 {{ x.wrapping_mul({i}).wrapping_add({}) }}\n",
|
||||
i + 1
|
||||
));
|
||||
}
|
||||
s.push_str("```\n");
|
||||
s
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
//! `mixed_interaction` — scroll while streaming. The real-world worst case.
|
||||
//!
|
||||
//! What it stresses: simultaneous cache invalidation (from streaming) and
|
||||
//! full viewport re-render (from scrolling). Surfaces `dirty_heights` /
|
||||
//! scroll-offset interactions.
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use super::{BenchResults, ContentController, PtyHarness, wait_for_welcome};
|
||||
use crate::keys;
|
||||
|
||||
const TARGET_WORDS: usize = 400;
|
||||
const SCROLL_KEYS: usize = 80;
|
||||
const KEY_INTERVAL: Duration = Duration::from_millis(40);
|
||||
|
||||
pub async fn run(harness: &mut PtyHarness, content: &ContentController) -> Result<BenchResults> {
|
||||
// Same payload shape as streaming_render, but we scroll through it
|
||||
// while it's still arriving.
|
||||
let mut body = String::from("mixed-bench ");
|
||||
for i in 0..TARGET_WORDS {
|
||||
body.push_str("tok");
|
||||
body.push_str(&i.to_string());
|
||||
body.push(' ');
|
||||
}
|
||||
content.set_response(body);
|
||||
|
||||
wait_for_welcome(harness).await?;
|
||||
harness.inject_keys(b"go\r")?;
|
||||
harness.wait_for_text("mixed-bench", Duration::from_secs(20))?;
|
||||
harness.reset_timing();
|
||||
|
||||
let start = Instant::now();
|
||||
for _ in 0..SCROLL_KEYS {
|
||||
harness.inject_keys(keys::J)?;
|
||||
harness.update(KEY_INTERVAL);
|
||||
if !harness.is_running() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
harness.update(Duration::from_millis(250));
|
||||
let wall_time = start.elapsed();
|
||||
|
||||
Ok(harness.bench_results("mixed_interaction", wall_time))
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
//! Named scenarios that drive content into the pager and measure frame timing.
|
||||
//!
|
||||
//! Each scenario is a function `async fn run(&mut PtyHarness, &ContentController)`
|
||||
//! returning a [`BenchResults`]. Scenarios are dispatched by name via the
|
||||
//! [`Scenario`] enum for the `pty-bench` CLI and for ad-hoc test usage.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Result;
|
||||
use clap::ValueEnum;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::{BenchResults, ContentController, PtyHarness};
|
||||
|
||||
pub mod empty_enter_send_now;
|
||||
pub mod idle_cost;
|
||||
pub mod large_codeblock;
|
||||
pub mod mixed_interaction;
|
||||
pub mod plan_approval_resume;
|
||||
pub mod resize_storm;
|
||||
pub mod scroll_stress;
|
||||
pub mod streaming_render;
|
||||
|
||||
/// Enumerates every benchmark scenario that can be dispatched by name.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, ValueEnum, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Scenario {
|
||||
/// Inject rapid `j` keys against a large pre-rendered response.
|
||||
ScrollStress,
|
||||
/// Stream a 2000-token response at a controlled rate.
|
||||
StreamingRender,
|
||||
/// Resize the PTY many times in quick succession; assert no crash.
|
||||
ResizeStorm,
|
||||
/// Render a large syntax-highlighted code block and scroll through it.
|
||||
LargeCodeblock,
|
||||
/// After content settles, measure CPU cost while idle.
|
||||
IdleCost,
|
||||
/// Scroll while streaming — the real-world worst case.
|
||||
MixedInteraction,
|
||||
}
|
||||
|
||||
impl Scenario {
|
||||
/// Every scenario, in dispatch order.
|
||||
pub const ALL: &'static [Scenario] = &[
|
||||
Scenario::ScrollStress,
|
||||
Scenario::StreamingRender,
|
||||
Scenario::ResizeStorm,
|
||||
Scenario::LargeCodeblock,
|
||||
Scenario::IdleCost,
|
||||
Scenario::MixedInteraction,
|
||||
];
|
||||
|
||||
/// Stable slug used in JSON output and baseline files.
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Scenario::ScrollStress => "scroll_stress",
|
||||
Scenario::StreamingRender => "streaming_render",
|
||||
Scenario::ResizeStorm => "resize_storm",
|
||||
Scenario::LargeCodeblock => "large_codeblock",
|
||||
Scenario::IdleCost => "idle_cost",
|
||||
Scenario::MixedInteraction => "mixed_interaction",
|
||||
}
|
||||
}
|
||||
|
||||
/// Dispatch to the scenario implementation.
|
||||
pub async fn run(
|
||||
self,
|
||||
harness: &mut PtyHarness,
|
||||
content: &ContentController,
|
||||
) -> Result<BenchResults> {
|
||||
match self {
|
||||
Scenario::ScrollStress => scroll_stress::run(harness, content).await,
|
||||
Scenario::StreamingRender => streaming_render::run(harness, content).await,
|
||||
Scenario::ResizeStorm => resize_storm::run(harness, content).await,
|
||||
Scenario::LargeCodeblock => large_codeblock::run(harness, content).await,
|
||||
Scenario::IdleCost => idle_cost::run(harness, content).await,
|
||||
Scenario::MixedInteraction => mixed_interaction::run(harness, content).await,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Wait for the pager to render the initial welcome screen. All scenarios
|
||||
/// that prompt / stream content rely on the pager being past startup.
|
||||
pub(crate) async fn wait_for_welcome(harness: &mut PtyHarness) -> Result<()> {
|
||||
// Menu label on the normal welcome (and gate menus): capital Q — see
|
||||
// `kigi-tui` `views/welcome/mod.rs` (`"Quit"` in `render_menu`).
|
||||
harness
|
||||
.wait_for_text("Quit", Duration::from_secs(15))
|
||||
.map_err(|e| anyhow::anyhow!("pager failed to reach welcome screen: {e}"))
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
//! Plan-approval chrome restored by the shell after quit + resume.
|
||||
//!
|
||||
//! When `exit_plan_mode` is parked and the user quits, the shell persists
|
||||
//! `awaiting_plan_approval = true` in `plan_mode.json`. On `--continue` the
|
||||
//! shell re-issues the `x.ai/exit_plan_mode` reverse-request — a real live ACP
|
||||
//! waiter — so the pager re-shows approval chrome through its normal path with
|
||||
//! no pager-side disk logic. Approving then leaves plan mode and starts the
|
||||
//! implement turn.
|
||||
//!
|
||||
//! This FAILS without the shell re-park (PR2 product change): no reverse-request
|
||||
//! reaches the resumed pager, so no approval chrome appears.
|
||||
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
|
||||
use super::wait_for_welcome;
|
||||
use crate::{ContentController, PtyHarness, pager_binary};
|
||||
|
||||
const DEFAULT_ROWS: u16 = 50;
|
||||
const DEFAULT_COLS: u16 = 120;
|
||||
const WELCOME_TIMEOUT: Duration = Duration::from_secs(20);
|
||||
/// Distinct per-turn sentinels: turn 1 seeds the session before quit; turn 2 is
|
||||
/// the implement turn the shell injects after the resumed approval is approved.
|
||||
const SETUP_SENTINEL: &str = "GBT3703SETUP";
|
||||
const IMPLEMENT_SENTINEL: &str = "GBT3703IMPLEMENTED";
|
||||
|
||||
const PLAN_BODY: &str = "\
|
||||
# Plan GBT3703Repro
|
||||
|
||||
## Steps
|
||||
1. Seed plan file on disk
|
||||
2. Quit pager with the approval parked
|
||||
3. Resume and expect restored approval chrome
|
||||
";
|
||||
|
||||
/// Regression: the shell re-parks `exit_plan_mode` on resume; pressing
|
||||
/// approve leaves plan mode and starts the implement turn.
|
||||
pub async fn assert_plan_approval_restored_after_resume() -> Result<()> {
|
||||
let content = ContentController::start()
|
||||
.await
|
||||
.context("start ContentController")?;
|
||||
// One response per agent turn (FIFO, 2+-tool requests only — aux requests
|
||||
// never steal one). Turn 1 is consumed by the first pager; turn 2 by the
|
||||
// implement turn the shell starts after approval.
|
||||
content.set_turns([
|
||||
format!("{SETUP_SENTINEL}: drafted a plan for the user to review."),
|
||||
format!("{IMPLEMENT_SENTINEL}: implementing the approved plan."),
|
||||
]);
|
||||
|
||||
let project = tempfile::tempdir().context("project dir")?;
|
||||
std::fs::create_dir_all(project.path().join(".git")).context("create .git")?;
|
||||
|
||||
let binary = pager_binary().context("resolve pager binary")?;
|
||||
let mut first = PtyHarness::spawn_with_content_in_dir(
|
||||
&binary,
|
||||
DEFAULT_ROWS,
|
||||
DEFAULT_COLS,
|
||||
&content,
|
||||
&[],
|
||||
Some(project.path()),
|
||||
)
|
||||
.context("spawn first pager")?;
|
||||
|
||||
wait_for_welcome(&mut first).await?;
|
||||
|
||||
first.inject_keys(b"go\r").context("submit setup turn")?;
|
||||
first
|
||||
.wait_for_text(SETUP_SENTINEL, Duration::from_secs(30))
|
||||
.context("setup turn rendered")?;
|
||||
|
||||
// Quit and reap BEFORE seeding so the still-live shell cannot re-persist
|
||||
// and clobber the seeded state.
|
||||
first.inject_keys(b"\x11").context("ctrl-q once")?;
|
||||
first.update(Duration::from_millis(200));
|
||||
first.inject_keys(b"\x11").context("ctrl-q confirm")?;
|
||||
first.quit().context("reap first pager")?;
|
||||
|
||||
let seeded = seed_parked_approval(content.home()).context("seed parked approval")?;
|
||||
assert!(seeded > 0, "no session dir seeded");
|
||||
|
||||
let mut resumed = PtyHarness::spawn_with_content_in_dir(
|
||||
&binary,
|
||||
DEFAULT_ROWS,
|
||||
DEFAULT_COLS,
|
||||
&content,
|
||||
&["--continue"],
|
||||
Some(project.path()),
|
||||
)
|
||||
.context("spawn resumed pager")?;
|
||||
|
||||
// The shell re-parks `exit_plan_mode` on resume, so approval chrome can open
|
||||
// immediately and cover chat history. Prefer the chrome markers (product
|
||||
// signal) over SETUP_SENTINEL, which may not be visible under the plan viewer.
|
||||
// Without the shell re-park this times out.
|
||||
resumed
|
||||
.wait_for_text("request changes", WELCOME_TIMEOUT)
|
||||
.context("restored approval 'request changes' after --continue")?;
|
||||
resumed
|
||||
.wait_for_text("quit plan", Duration::from_secs(5))
|
||||
.context("restored approval 'quit plan' after resume")?;
|
||||
let screen = resumed.screen_contents();
|
||||
if !screen.contains("approve") {
|
||||
bail!("expected approval primary action after resume\n{screen}");
|
||||
}
|
||||
// History was seeded before quit; plan body from disk is a stronger signal
|
||||
// that the session was restored when chrome already covers the transcript.
|
||||
if !screen.contains("GBT3703Repro")
|
||||
&& !screen.contains(SETUP_SENTINEL)
|
||||
&& !screen.contains("Seed plan file on disk")
|
||||
{
|
||||
bail!("expected resumed session content (plan body or setup sentinel)\n{screen}");
|
||||
}
|
||||
if resumed.contains_text("panicked") {
|
||||
bail!("pager panicked\n{screen}");
|
||||
}
|
||||
|
||||
// Approve: the shell leaves plan mode and injects the implement turn.
|
||||
resumed.inject_keys(b"a").context("press 'a' to approve")?;
|
||||
resumed
|
||||
.wait_for_text(IMPLEMENT_SENTINEL, Duration::from_secs(30))
|
||||
.context("approve must leave plan mode and start the implement turn")?;
|
||||
|
||||
resumed.quit().context("quit resumed pager")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Mark the persisted session as having a parked plan approval: write `plan.md`
|
||||
/// and flip `awaiting_plan_approval` to `true` in `plan_mode.json` for every
|
||||
/// session dir under the sandbox home.
|
||||
fn seed_parked_approval(home: &Path) -> Result<usize> {
|
||||
let sessions_root = home.join(".kigi").join("sessions");
|
||||
if !sessions_root.is_dir() {
|
||||
bail!(
|
||||
"expected sessions under {} after first turn",
|
||||
sessions_root.display()
|
||||
);
|
||||
}
|
||||
let mut seeded = 0usize;
|
||||
for cwd_ent in std::fs::read_dir(&sessions_root).context("read sessions root")? {
|
||||
let cwd_ent = cwd_ent.context("cwd entry")?;
|
||||
if !cwd_ent.file_type().context("ft")?.is_dir() {
|
||||
continue;
|
||||
}
|
||||
for sess_ent in std::fs::read_dir(cwd_ent.path()).context("read cwd sessions")? {
|
||||
let sess_ent = sess_ent.context("session entry")?;
|
||||
if !sess_ent.file_type().context("ft")?.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let dir = sess_ent.path();
|
||||
std::fs::write(dir.join("plan.md"), PLAN_BODY).context("write plan.md")?;
|
||||
write_awaiting_plan_mode(&dir.join("plan_mode.json"))?;
|
||||
seeded += 1;
|
||||
}
|
||||
}
|
||||
if seeded == 0 {
|
||||
bail!(
|
||||
"expected at least one session dir under {}",
|
||||
sessions_root.display()
|
||||
);
|
||||
}
|
||||
Ok(seeded)
|
||||
}
|
||||
|
||||
/// Round-trip the shell-written `plan_mode.json` and flip `awaiting_plan_approval`
|
||||
/// to `true`, preserving every other field. Falls back to a fresh Active
|
||||
/// snapshot if the shell wrote nothing. The shape mirrors
|
||||
/// `kigi_shell::session::plan_mode::PlanModeSnapshot`; we only touch the one
|
||||
/// field (robust to schema growth) rather than depend on the heavy shell crate
|
||||
/// from this test-only harness.
|
||||
fn write_awaiting_plan_mode(path: &Path) -> Result<()> {
|
||||
let mut value: serde_json::Value = std::fs::read_to_string(path)
|
||||
.ok()
|
||||
.and_then(|raw| serde_json::from_str(&raw).ok())
|
||||
.unwrap_or_else(|| {
|
||||
serde_json::json!({
|
||||
"state": "Active",
|
||||
"was_previously_active": true,
|
||||
"reminder_count": 0,
|
||||
"pending_exit_reminder": false,
|
||||
})
|
||||
});
|
||||
let obj = value
|
||||
.as_object_mut()
|
||||
.context("plan_mode.json must be a JSON object")?;
|
||||
// Must be Active for the re-park; awaiting flag is the trigger.
|
||||
obj.insert("state".into(), serde_json::Value::String("Active".into()));
|
||||
obj.insert(
|
||||
"awaiting_plan_approval".into(),
|
||||
serde_json::Value::Bool(true),
|
||||
);
|
||||
std::fs::write(path, serde_json::to_vec_pretty(&value)?).context("write plan_mode.json")?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
//! `resize_storm` — resize the PTY many times in quick succession; assert
|
||||
//! no crash and measure recovery.
|
||||
//!
|
||||
//! What it stresses: `prepare_layout` Case 1 (full width-change rebuild),
|
||||
//! wrap-cache misses across every entry, resize-debounce in `event_loop.rs`.
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::{Result, anyhow};
|
||||
|
||||
use super::{BenchResults, ContentController, PtyHarness, wait_for_welcome};
|
||||
|
||||
const RESIZES: usize = 25;
|
||||
const RESIZE_INTERVAL: Duration = Duration::from_millis(40);
|
||||
|
||||
pub async fn run(harness: &mut PtyHarness, _content: &ContentController) -> Result<BenchResults> {
|
||||
wait_for_welcome(harness).await?;
|
||||
harness.reset_timing();
|
||||
|
||||
let start = Instant::now();
|
||||
// Oscillate between a narrow and a wide layout.
|
||||
for i in 0..RESIZES {
|
||||
let (rows, cols) = if i % 2 == 0 { (35, 100) } else { (55, 160) };
|
||||
harness.resize(rows, cols)?;
|
||||
harness.update(RESIZE_INTERVAL);
|
||||
if !harness.is_running() {
|
||||
return Err(anyhow!("pager exited during resize_storm at iter {i}"));
|
||||
}
|
||||
}
|
||||
// Let the pager settle.
|
||||
harness.update(Duration::from_millis(500));
|
||||
let wall_time = start.elapsed();
|
||||
|
||||
if harness.contains_text("panicked") {
|
||||
return Err(anyhow!(
|
||||
"pager rendered 'panicked' during resize_storm\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
));
|
||||
}
|
||||
|
||||
Ok(harness.bench_results("resize_storm", wall_time))
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
//! `scroll_stress` — inject rapid `j` keys against a large pre-rendered
|
||||
//! response, measure frame timing.
|
||||
//!
|
||||
//! What it stresses: `render_scrolled_entries_with_scratch`, partial-entry
|
||||
//! clipping (ScratchBuffer cell copy), `Buffer::diff()`.
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use super::{BenchResults, ContentController, PtyHarness, wait_for_welcome};
|
||||
use crate::keys;
|
||||
|
||||
const LINES: usize = 500;
|
||||
const SCROLL_KEYS: usize = 200;
|
||||
const KEY_INTERVAL: Duration = Duration::from_millis(16);
|
||||
|
||||
pub async fn run(harness: &mut PtyHarness, content: &ContentController) -> Result<BenchResults> {
|
||||
// 1. Prime the mock server with a large markdown response.
|
||||
content.set_response(long_markdown_response(LINES));
|
||||
|
||||
// 2. Wait for the pager's splash screen.
|
||||
wait_for_welcome(harness).await?;
|
||||
|
||||
// 3. Submit a prompt so the mock inference server returns the big response.
|
||||
// The pager is interactive: type a short prompt then hit Enter.
|
||||
harness.inject_keys(b"go\r")?;
|
||||
|
||||
// 4. Wait until the streamed response is on screen.
|
||||
// The response is `Lorem ipsum dolor ...` — look for a word we know
|
||||
// will appear after streaming starts.
|
||||
harness.wait_for_text("Lorem", Duration::from_secs(30))?;
|
||||
|
||||
// 5. Let the full response settle, then reset timing to start clean.
|
||||
harness.update(Duration::from_millis(500));
|
||||
harness.reset_timing();
|
||||
|
||||
// 6. Inject scroll-down keys at a fixed interval while collecting frames.
|
||||
let wall_start = Instant::now();
|
||||
for _ in 0..SCROLL_KEYS {
|
||||
harness.inject_keys(keys::J)?;
|
||||
harness.update(KEY_INTERVAL);
|
||||
if !harness.is_running() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Drain any straggler frames.
|
||||
harness.update(Duration::from_millis(250));
|
||||
let wall_time = wall_start.elapsed();
|
||||
|
||||
Ok(harness.bench_results("scroll_stress", wall_time))
|
||||
}
|
||||
|
||||
/// Generate `n` lines of predictable markdown. Used both as scenario
|
||||
/// payload and as a basic smoke test of the wrapping pipeline.
|
||||
fn long_markdown_response(n: usize) -> String {
|
||||
let mut out = String::with_capacity(n * 80);
|
||||
out.push_str("# Scroll stress response\n\n");
|
||||
for i in 0..n {
|
||||
out.push_str(&format!(
|
||||
"Line {i}: Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.\n"
|
||||
));
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
//! `streaming_render` — stream a response and measure frame timing during
|
||||
//! active streaming.
|
||||
//!
|
||||
//! What it stresses: streaming-chunk cache invalidation, `ensure_wrapped()`
|
||||
//! cache misses every generation, wave animation during running turn.
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use super::{BenchResults, ContentController, PtyHarness, wait_for_welcome};
|
||||
|
||||
const TARGET_WORDS: usize = 400;
|
||||
const STREAM_WINDOW: Duration = Duration::from_secs(4);
|
||||
|
||||
pub async fn run(harness: &mut PtyHarness, content: &ContentController) -> Result<BenchResults> {
|
||||
content.set_response(build_response(TARGET_WORDS));
|
||||
|
||||
wait_for_welcome(harness).await?;
|
||||
|
||||
// Kick off the streamed response.
|
||||
harness.inject_keys(b"stream\r")?;
|
||||
// Wait for first delta to hit the screen so we're measuring the
|
||||
// steady-state streaming path, not startup latency.
|
||||
harness.wait_for_text("stream-bench", Duration::from_secs(20))?;
|
||||
harness.reset_timing();
|
||||
|
||||
// Collect frames during the streaming window — the mock server paces
|
||||
// itself naturally via HTTP/SSE; we just let the pipe drain.
|
||||
let start = Instant::now();
|
||||
while start.elapsed() < STREAM_WINDOW {
|
||||
harness.update(Duration::from_millis(100));
|
||||
if !harness.is_running() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let wall_time = start.elapsed();
|
||||
|
||||
Ok(harness.bench_results("streaming_render", wall_time))
|
||||
}
|
||||
|
||||
fn build_response(words: usize) -> String {
|
||||
let mut s = String::with_capacity(words * 10);
|
||||
// Sentinel token that wait_for_text keys on — guaranteed to appear
|
||||
// near the very start of the stream.
|
||||
s.push_str("stream-bench ");
|
||||
for i in 0..words {
|
||||
s.push_str("word");
|
||||
s.push_str(&i.to_string());
|
||||
s.push(' ');
|
||||
}
|
||||
s
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
//! Layer 2a: Screen state tracking via `alacritty_terminal` (ptyctl).
|
||||
//!
|
||||
//! Parses raw PTY output through a headless terminal emulator and provides
|
||||
//! queries for what the user would see on screen.
|
||||
|
||||
use ptyctl::styled::StyledLine;
|
||||
use ptyctl::term::{ScreenOpts, ScreenOutput, SessionListener, Terminal};
|
||||
|
||||
/// Tracks the virtual terminal screen state by feeding raw PTY output
|
||||
/// through an `alacritty_terminal`-based headless terminal (via ptyctl).
|
||||
pub struct ScreenTracker {
|
||||
terminal: Terminal,
|
||||
/// Receives terminal-generated replies (cursor-position reports, device
|
||||
/// attributes, color queries, …) the emulator emits while parsing input.
|
||||
/// Drained by [`ScreenTracker::drain_responses`] so the harness can forward
|
||||
/// them back to the child (real terminals answer these automatically).
|
||||
pty_write_rx: tokio::sync::mpsc::UnboundedReceiver<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl ScreenTracker {
|
||||
/// Create a new tracker for a terminal with the given dimensions.
|
||||
pub fn new(rows: u16, cols: u16) -> Self {
|
||||
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let listener = SessionListener::new(tx);
|
||||
Self {
|
||||
terminal: Terminal::new(cols, rows, listener),
|
||||
pty_write_rx: rx,
|
||||
}
|
||||
}
|
||||
|
||||
/// Feed raw PTY output bytes into the terminal emulator.
|
||||
pub fn feed(&mut self, bytes: &[u8]) {
|
||||
self.terminal.feed(bytes);
|
||||
}
|
||||
|
||||
/// Drain any terminal-generated replies queued while parsing fed input
|
||||
/// (cursor-position reports answering `ESC[6n`, device attributes, color
|
||||
/// queries, …), concatenated in order. Empty when nothing was queued.
|
||||
///
|
||||
/// These MUST be written back to the PTY or programs that probe the
|
||||
/// terminal will hang or time out — most relevant here, the inline
|
||||
/// viewport's startup cursor-position query that minimal mode depends on
|
||||
/// (a timeout there downgrades `--minimal` to full-screen inline). A real
|
||||
/// terminal answers automatically; the harness forwards these in
|
||||
/// [`crate::PtyHarness::update`] when response forwarding is enabled.
|
||||
pub fn drain_responses(&mut self) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
while let Ok(bytes) = self.pty_write_rx.try_recv() {
|
||||
out.extend_from_slice(&bytes);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Return structured screen contents (no escape codes).
|
||||
pub fn output(&self) -> ScreenOutput {
|
||||
self.terminal.screen_content(&ScreenOpts::default())
|
||||
}
|
||||
|
||||
/// Return the full text contents of the screen (no escape codes).
|
||||
pub fn contents(&self) -> String {
|
||||
self.output().lines.join("\n")
|
||||
}
|
||||
|
||||
/// Check whether the screen contains the given text substring.
|
||||
pub fn contains(&self, text: &str) -> bool {
|
||||
self.contents().contains(text)
|
||||
}
|
||||
|
||||
/// Return the current cursor position as `(row, col)` (0-indexed, matching
|
||||
/// the original vt100 convention used by existing tests).
|
||||
pub fn cursor_position(&self) -> (u16, u16) {
|
||||
let pos = self.terminal.cursor_position();
|
||||
// ptyctl cursor is 1-indexed; the harness API is 0-indexed.
|
||||
(
|
||||
(pos.row as u16).saturating_sub(1),
|
||||
(pos.col as u16).saturating_sub(1),
|
||||
)
|
||||
}
|
||||
|
||||
/// Resize the virtual terminal to new dimensions.
|
||||
pub fn resize(&mut self, rows: u16, cols: u16) {
|
||||
self.terminal.resize(cols, rows);
|
||||
}
|
||||
|
||||
/// Return the full screen with style information for visual artifacts.
|
||||
pub fn styled(&self) -> Vec<StyledLine> {
|
||||
self.terminal.screen_styled(&ScreenOpts::default())
|
||||
}
|
||||
|
||||
/// Render the current screen as an HTML document.
|
||||
pub fn html(&self) -> String {
|
||||
self.terminal.screen_html(&ScreenOpts::default())
|
||||
}
|
||||
|
||||
/// Access the underlying ptyctl `Terminal` for advanced queries
|
||||
/// (styled output, scrollback, terminal modes, etc.).
|
||||
pub fn terminal(&self) -> &Terminal {
|
||||
&self.terminal
|
||||
}
|
||||
|
||||
/// Number of lines in the terminal's scrollback history — content that has
|
||||
/// scrolled *above* the visible screen. This is where minimal mode's
|
||||
/// committed conversation blocks land (printed via `insert_before`).
|
||||
pub fn scrollback_count(&self) -> usize {
|
||||
self.terminal.scrollback_count()
|
||||
}
|
||||
|
||||
/// The full scrollback history as text, oldest line first.
|
||||
pub fn scrollback_text(&self) -> String {
|
||||
let n = self.terminal.scrollback_count();
|
||||
self.terminal
|
||||
.scrollback_lines(n)
|
||||
.into_iter()
|
||||
.map(|l| l.text)
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
/// Scrollback history plus the visible screen, joined oldest→newest:
|
||||
/// everything a user could see by scrolling up. Minimal-mode committed
|
||||
/// content may be in either region depending on how much has accumulated,
|
||||
/// so assertions on committed output should use this.
|
||||
pub fn full_text(&self) -> String {
|
||||
let sb = self.scrollback_text();
|
||||
let screen = self.contents();
|
||||
if sb.is_empty() {
|
||||
screen
|
||||
} else {
|
||||
format!("{sb}\n{screen}")
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether scrollback + visible screen contains `text`.
|
||||
pub fn full_contains(&self, text: &str) -> bool {
|
||||
self.full_text().contains(text)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Lines pushed above a small screen must be readable via the scrollback
|
||||
/// helpers — the property minimal-mode e2e tests rely on to assert that a
|
||||
/// committed block reached native scrollback.
|
||||
#[test]
|
||||
fn scrolled_off_lines_are_captured_by_scrollback_helpers() {
|
||||
// 3-row screen; print 8 numbered lines so the first ones scroll off.
|
||||
let mut s = ScreenTracker::new(3, 20);
|
||||
for i in 1..=8 {
|
||||
s.feed(format!("line{i}\r\n").as_bytes());
|
||||
}
|
||||
// The earliest lines are no longer on the visible screen…
|
||||
assert!(
|
||||
!s.contains("line1"),
|
||||
"line1 should have scrolled off-screen"
|
||||
);
|
||||
// …but they are in scrollback, and full_text sees everything.
|
||||
assert!(s.scrollback_count() >= 5, "expected scrolled-off history");
|
||||
assert!(s.scrollback_text().contains("line1"));
|
||||
assert!(s.full_contains("line1"));
|
||||
assert!(s.full_contains("line8"));
|
||||
}
|
||||
|
||||
/// A DSR cursor-position query (`ESC[6n`) must produce a forwardable reply
|
||||
/// (a CPR `ESC[<row>;<col>R`) — the mechanism minimal-mode tests rely on so
|
||||
/// the inline viewport's startup cursor query completes. Without forwarding,
|
||||
/// `--minimal` silently downgrades to full-screen inline.
|
||||
#[test]
|
||||
fn drain_responses_answers_cursor_position_query() {
|
||||
let mut s = ScreenTracker::new(24, 80);
|
||||
// Nothing queued before any query is fed.
|
||||
assert!(s.drain_responses().is_empty());
|
||||
|
||||
s.feed(b"\x1b[6n");
|
||||
let reply = s.drain_responses();
|
||||
assert!(
|
||||
reply.starts_with(b"\x1b[") && reply.ends_with(b"R"),
|
||||
"expected a cursor-position report, got {:?}",
|
||||
String::from_utf8_lossy(&reply)
|
||||
);
|
||||
|
||||
// Drained exactly once — no duplicate delivery on the next call.
|
||||
assert!(s.drain_responses().is_empty());
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,403 @@
|
||||
//! The cell table: terminal-class × config × gesture rows the matrix runs.
|
||||
//!
|
||||
//! Classes are `ScrollConfig` equivalence classes, not brands — every brand
|
||||
//! sharing a profile is represented once (`from_terminal_context` in the
|
||||
//! pager's `mouse.rs` is the source of truth):
|
||||
//!
|
||||
//! | class | env | ept | wheel_lpt | trackpad_lpt |
|
||||
//! |-------|----------------------------|-----|-----------|--------------|
|
||||
//! | C1 | none (harness strips) | 3 | 3 | 3 |
|
||||
//! | C2 | `TERM_PROGRAM=iTerm.app` | 1 | 1 | 3 |
|
||||
//! | C3 | `TERM_PROGRAM=zed` | 1 | 3 | 3 |
|
||||
//! | C4 | `TERM_PROGRAM=vscode` | 1 | 3 | 15 |
|
||||
//! | C5 | `TMUX=…` (remuxed) | 1 | 1 | 3 |
|
||||
//!
|
||||
//! Mux honesty: C5's env only exercises profile *selection* (the pager
|
||||
//! can't tell a fake `TMUX` from a real one); real tmux event-mangling is
|
||||
//! simulated by the G9 gesture shapes, and a real-tmux tier stays local.
|
||||
//!
|
||||
//! Trim note: the design sketched ~40 rows; this table ships the curated 8
|
||||
//! plus 17 representative full-tier rows (every class, every gesture, every
|
||||
//! config knob at least once) to hold the A12 size budget — growing the
|
||||
//! full tier is additive row work.
|
||||
|
||||
use super::gestures::GestureId;
|
||||
use super::invariants::InvariantId;
|
||||
use super::session::SessionKind;
|
||||
|
||||
/// The config echo a cell expects on every `stream_start` (I-CFG) and the
|
||||
/// pricing inputs the consistency invariants use.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct ExpectedProfile {
|
||||
/// `mode` label: `auto` | `wheel` | `trackpad`.
|
||||
pub mode: &'static str,
|
||||
pub ept: u16,
|
||||
pub wheel_lpt: u16,
|
||||
pub trackpad_lpt: u16,
|
||||
pub invert: bool,
|
||||
/// Speed multiplier (NOT the 1-100 setting): `KIGI_SCROLL_SPEED=100`
|
||||
/// echoes 6.0 via the pager's `speed_to_multiplier`.
|
||||
pub speed: f32,
|
||||
}
|
||||
|
||||
/// Execution tier: `Curated` runs in CI (A13's runner test); `Full` only in
|
||||
/// the local full sweep.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Tier {
|
||||
Curated,
|
||||
Full,
|
||||
}
|
||||
|
||||
/// One matrix cell: environment in, gesture replayed, invariants judged.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct MatrixCell {
|
||||
pub id: &'static str,
|
||||
pub tier: Tier,
|
||||
/// Pager env pairs (terminal-class markers + config vars). The runner
|
||||
/// appends `KIGI_SCROLL_LOG`; the harness's env strips guarantee the
|
||||
/// host terminal can't leak competing markers underneath these.
|
||||
pub env: &'static [(&'static str, &'static str)],
|
||||
pub expected: ExpectedProfile,
|
||||
pub gesture: GestureId,
|
||||
pub session: SessionKind,
|
||||
/// Invariants judged for this cell (harness-side ids included; the
|
||||
/// runner routes by `InvariantId::is_log_side`).
|
||||
pub invariants: &'static [InvariantId],
|
||||
/// Invariants expected to VIOLATE on current code (known bugs, e.g. the
|
||||
/// G4 jerk until A13's finalize-decel fix). Must be ⊆ `invariants`; the
|
||||
/// runner fails a cell on any non-xfail violation AND on an xfail PASS
|
||||
/// (a fixed bug must be promoted out of xfail, not silently absorbed).
|
||||
pub xfail: &'static [InvariantId],
|
||||
}
|
||||
|
||||
const C1: ExpectedProfile = ExpectedProfile {
|
||||
mode: "auto",
|
||||
ept: 3,
|
||||
wheel_lpt: 3,
|
||||
trackpad_lpt: 3,
|
||||
invert: false,
|
||||
speed: 1.0,
|
||||
};
|
||||
const C2: ExpectedProfile = ExpectedProfile {
|
||||
ept: 1,
|
||||
wheel_lpt: 1,
|
||||
..C1
|
||||
};
|
||||
const C3: ExpectedProfile = ExpectedProfile { ept: 1, ..C1 };
|
||||
const C4: ExpectedProfile = ExpectedProfile {
|
||||
ept: 1,
|
||||
trackpad_lpt: 15,
|
||||
..C1
|
||||
};
|
||||
/// Remuxed conservative profile — identical numbers to C2 by design
|
||||
/// (`multiplexer_reencodes_mouse` forces ept=1/wheel_lpt=1).
|
||||
const C5: ExpectedProfile = C2;
|
||||
|
||||
const ITERM: (&str, &str) = ("TERM_PROGRAM", "iTerm.app");
|
||||
const ZED: (&str, &str) = ("TERM_PROGRAM", "zed");
|
||||
const VSCODE: (&str, &str) = ("TERM_PROGRAM", "vscode");
|
||||
const TMUX: (&str, &str) = ("TMUX", "/tmp/tmux-0/default,1,0");
|
||||
const SPEED100: (&str, &str) = ("KIGI_SCROLL_SPEED", "100");
|
||||
const MODE_WHEEL: (&str, &str) = ("KIGI_SCROLL_MODE", "wheel");
|
||||
const MODE_TRACKPAD: (&str, &str) = ("KIGI_SCROLL_MODE", "trackpad");
|
||||
const LINES1: (&str, &str) = ("KIGI_SCROLL_LINES", "1");
|
||||
const INVERT: (&str, &str) = ("KIGI_INVERT_SCROLL", "1");
|
||||
|
||||
use InvariantId::*;
|
||||
|
||||
/// Core log-side suite for auto-mode cells.
|
||||
const AUTO: &[InvariantId] = &[Ord, Cap, DropEq, Cadence, ConsA, Accel, Carry, Cfg];
|
||||
const AUTO_NODROP: &[InvariantId] = &[Ord, Cap, DropEq, Cadence, ConsA, Accel, Carry, Cfg, NoDrop];
|
||||
/// Floods keep the core suite (drops are legitimate) + post-gesture quiet.
|
||||
const AUTO_QUIET: &[InvariantId] = &[Ord, Cap, DropEq, Cadence, ConsA, Accel, Carry, Cfg, Quiet];
|
||||
/// Forced wheel: exact totals; nothing may drop on notch-scale gestures.
|
||||
const WHEEL: &[InvariantId] = &[Ord, Cap, DropEq, Cadence, ConsW, Accel, Carry, Cfg, NoDrop];
|
||||
const WHEEL_MUX: &[InvariantId] = &[
|
||||
Ord, Cap, DropEq, Cadence, ConsW, Accel, Carry, Cfg, NoDrop, MuxNoOver,
|
||||
];
|
||||
const AUTO_MUX_NODROP: &[InvariantId] = &[
|
||||
Ord, Cap, DropEq, Cadence, ConsA, Accel, Carry, Cfg, NoDrop, MuxNoOver,
|
||||
];
|
||||
const AUTO_SCREEN: &[InvariantId] = &[Ord, Cap, DropEq, Cadence, ConsA, Accel, Carry, Cfg, Screen];
|
||||
/// The jerk suite: core + the two smoothness invariants the finalize-decel
|
||||
/// fix made hold (formerly this cell's xfail set).
|
||||
const JERK: &[InvariantId] = &[
|
||||
Ord,
|
||||
Cap,
|
||||
DropEq,
|
||||
Cadence,
|
||||
ConsA,
|
||||
Accel,
|
||||
Carry,
|
||||
Cfg,
|
||||
SmoothCoast,
|
||||
NoDrop,
|
||||
];
|
||||
|
||||
const fn cell(
|
||||
id: &'static str,
|
||||
tier: Tier,
|
||||
env: &'static [(&'static str, &'static str)],
|
||||
expected: ExpectedProfile,
|
||||
gesture: GestureId,
|
||||
session: SessionKind,
|
||||
invariants: &'static [InvariantId],
|
||||
) -> MatrixCell {
|
||||
MatrixCell {
|
||||
id,
|
||||
tier,
|
||||
env,
|
||||
expected,
|
||||
gesture,
|
||||
session,
|
||||
invariants,
|
||||
xfail: &[],
|
||||
}
|
||||
}
|
||||
|
||||
/// The matrix. Ids are `<class>_<config>_<gesture>[_qualifier]`.
|
||||
#[rustfmt::skip]
|
||||
pub const CELLS: &[MatrixCell] = &[
|
||||
// ── Curated (CI tier, 8 cells) ─────────────────────────────────────
|
||||
cell("c1_auto_g3_flood_speed100", Tier::Curated, &[SPEED100],
|
||||
ExpectedProfile { speed: 6.0, ..C1 }, GestureId::G3Flood, SessionKind::Settled, AUTO_QUIET),
|
||||
cell("c2_auto_g3_flood_speed100", Tier::Curated, &[ITERM, SPEED100],
|
||||
ExpectedProfile { speed: 6.0, ..C2 }, GestureId::G3Flood, SessionKind::Settled, AUTO_QUIET),
|
||||
cell("c3_wheel_lines1_g1", Tier::Curated, &[ZED, MODE_WHEEL, LINES1],
|
||||
ExpectedProfile { mode: "wheel", wheel_lpt: 1, trackpad_lpt: 1, ..C3 },
|
||||
GestureId::G1Notch, SessionKind::Settled, WHEEL),
|
||||
cell("c4_auto_g10_ambiguous", Tier::Curated, &[VSCODE],
|
||||
C4, GestureId::G10AmbiguousSlow, SessionKind::Settled, AUTO_NODROP),
|
||||
cell("c5_tmux_g9a", Tier::Curated, &[TMUX],
|
||||
C5, GestureId::G9aMuxSingles, SessionKind::Settled, AUTO_MUX_NODROP),
|
||||
cell("c5_tmux_g9b", Tier::Curated, &[TMUX],
|
||||
C5, GestureId::G9bMuxBatch, SessionKind::Settled, AUTO_MUX_NODROP),
|
||||
cell("c1_auto_g8_midstream", Tier::Curated, &[],
|
||||
C1, GestureId::G8MidStreamTrain, SessionKind::Streaming, AUTO),
|
||||
// Id kept for artifact/test continuity: the cell pinned the G4 jerk as
|
||||
// xfail until the finalize-decel fix; its former xfail rows
|
||||
// (I-SMOOTH-COAST, I-NO-DROP) are ordinary pass rows now.
|
||||
cell("c1_auto_g4_jerk_xfail", Tier::Curated, &[],
|
||||
C1, GestureId::G4Jerk, SessionKind::Settled, JERK),
|
||||
// ── Full tier (local sweep; representative subset — see trim note) ─
|
||||
cell("c1_auto_g1", Tier::Full, &[], C1,
|
||||
GestureId::G1Notch, SessionKind::Settled, AUTO_NODROP),
|
||||
cell("c1_auto_g2", Tier::Full, &[], C1,
|
||||
GestureId::G2NotchTrain, SessionKind::Settled, AUTO_NODROP),
|
||||
cell("c1_auto_g5_ghostty_dup", Tier::Full, &[], C1,
|
||||
GestureId::G5GhosttyDup, SessionKind::Settled, AUTO_NODROP),
|
||||
cell("c1_auto_g6_flip", Tier::Full, &[], C1,
|
||||
GestureId::G6Flip, SessionKind::Settled, AUTO),
|
||||
cell("c1_auto_g7_overscroll", Tier::Full, &[], C1,
|
||||
GestureId::G7Overscroll, SessionKind::BottomPinned, AUTO_SCREEN),
|
||||
cell("c1_wheel_g2", Tier::Full, &[MODE_WHEEL], ExpectedProfile { mode: "wheel", ..C1 },
|
||||
GestureId::G2NotchTrain, SessionKind::Settled, WHEEL),
|
||||
cell("c1_trackpad_g3_flood", Tier::Full, &[MODE_TRACKPAD],
|
||||
ExpectedProfile { mode: "trackpad", ..C1 },
|
||||
GestureId::G3Flood, SessionKind::Settled, AUTO_QUIET),
|
||||
cell("c1_auto_g11_carry", Tier::Full, &[], C1,
|
||||
GestureId::G11Carry, SessionKind::Settled, AUTO_NODROP),
|
||||
cell("c1_invert_g1", Tier::Full, &[INVERT], ExpectedProfile { invert: true, ..C1 },
|
||||
GestureId::G1Notch, SessionKind::Settled, AUTO_NODROP),
|
||||
cell("c2_auto_g1", Tier::Full, &[ITERM], C2,
|
||||
GestureId::G1Notch, SessionKind::Settled, AUTO_NODROP),
|
||||
cell("c2_wheel_g1", Tier::Full, &[ITERM, MODE_WHEEL],
|
||||
ExpectedProfile { mode: "wheel", ..C2 },
|
||||
GestureId::G1Notch, SessionKind::Settled, WHEEL),
|
||||
cell("c3_auto_g1", Tier::Full, &[ZED], C3,
|
||||
GestureId::G1Notch, SessionKind::Settled, AUTO_NODROP),
|
||||
cell("c3_auto_g10_repricing", Tier::Full, &[ZED], C3,
|
||||
GestureId::G10AmbiguousSlow, SessionKind::Settled, AUTO_NODROP),
|
||||
cell("c4_auto_g3_flood", Tier::Full, &[VSCODE], C4,
|
||||
GestureId::G3Flood, SessionKind::Settled, AUTO_QUIET),
|
||||
cell("c4_lines1_g10", Tier::Full, &[VSCODE, LINES1],
|
||||
ExpectedProfile { wheel_lpt: 1, trackpad_lpt: 1, ..C4 },
|
||||
GestureId::G10AmbiguousSlow, SessionKind::Settled, AUTO_NODROP),
|
||||
cell("c5_wheel_g9b", Tier::Full, &[TMUX, MODE_WHEEL],
|
||||
ExpectedProfile { mode: "wheel", ..C5 },
|
||||
GestureId::G9bMuxBatch, SessionKind::Settled, WHEEL_MUX),
|
||||
cell("c5_auto_g6_flip", Tier::Full, &[TMUX], C5,
|
||||
GestureId::G6Flip, SessionKind::Settled, AUTO),
|
||||
];
|
||||
|
||||
/// The CI subset.
|
||||
pub fn curated() -> impl Iterator<Item = &'static MatrixCell> {
|
||||
CELLS.iter().filter(|c| c.tier == Tier::Curated)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashSet;
|
||||
|
||||
/// Re-derive the expected profile from a cell's env pairs the way the
|
||||
/// pager would (`from_terminal_context` + the `KIGI_SCROLL_*` env
|
||||
/// overrides) — a tripwire against rows drifting from `mouse.rs`.
|
||||
fn derive_expected(env: &'static [(&'static str, &'static str)]) -> ExpectedProfile {
|
||||
let get = |k: &str| env.iter().find(|(key, _)| *key == k).map(|(_, v)| *v);
|
||||
let remuxed = get("TMUX").is_some();
|
||||
let (ept, mut wheel_lpt, mut trackpad_lpt) = if remuxed {
|
||||
(1, 1, 3)
|
||||
} else {
|
||||
match get("TERM_PROGRAM") {
|
||||
Some("iTerm.app") => (1, 1, 3),
|
||||
Some("zed") => (1, 3, 3),
|
||||
Some("vscode") => (1, 3, 15),
|
||||
None => (3, 3, 3),
|
||||
Some(other) => panic!("unmapped TERM_PROGRAM {other:?}"),
|
||||
}
|
||||
};
|
||||
if let Some(lines) = get("KIGI_SCROLL_LINES") {
|
||||
let lines: u16 = lines.parse().unwrap();
|
||||
wheel_lpt = lines;
|
||||
trackpad_lpt = lines; // one knob overrides both paths
|
||||
}
|
||||
// speed_to_multiplier re-derivation for the settings used in rows.
|
||||
let speed = match get("KIGI_SCROLL_SPEED") {
|
||||
None | Some("50") => 1.0,
|
||||
Some("100") => 6.0,
|
||||
Some(other) => panic!("unmapped KIGI_SCROLL_SPEED {other:?}"),
|
||||
};
|
||||
ExpectedProfile {
|
||||
mode: get("KIGI_SCROLL_MODE").unwrap_or("auto"),
|
||||
ept,
|
||||
wheel_lpt,
|
||||
trackpad_lpt,
|
||||
invert: get("KIGI_INVERT_SCROLL") == Some("1"),
|
||||
speed,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ids_are_unique() {
|
||||
let mut seen = HashSet::new();
|
||||
for cell in CELLS {
|
||||
assert!(seen.insert(cell.id), "duplicate cell id {}", cell.id);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn curated_tier_is_the_designed_eight() {
|
||||
let ids: Vec<&str> = curated().map(|c| c.id).collect();
|
||||
assert_eq!(
|
||||
ids,
|
||||
[
|
||||
"c1_auto_g3_flood_speed100",
|
||||
"c2_auto_g3_flood_speed100",
|
||||
"c3_wheel_lines1_g1",
|
||||
"c4_auto_g10_ambiguous",
|
||||
"c5_tmux_g9a",
|
||||
"c5_tmux_g9b",
|
||||
"c1_auto_g8_midstream",
|
||||
"c1_auto_g4_jerk_xfail",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xfail_is_a_subset_of_the_cell_invariants() {
|
||||
for cell in CELLS {
|
||||
for id in cell.xfail {
|
||||
assert!(
|
||||
cell.invariants.contains(id),
|
||||
"{}: xfail {} not in the invariant list",
|
||||
cell.id,
|
||||
id.as_str()
|
||||
);
|
||||
}
|
||||
}
|
||||
// The finalize-decel fix promoted the jerk cell's xfail rows
|
||||
// (I-SMOOTH-COAST, I-NO-DROP) into ordinary pass rows: the table
|
||||
// must carry no xfail anywhere until the next pinned bug.
|
||||
let jerk = CELLS
|
||||
.iter()
|
||||
.find(|c| c.id == "c1_auto_g4_jerk_xfail")
|
||||
.unwrap();
|
||||
assert!(
|
||||
jerk.xfail.is_empty(),
|
||||
"the jerk is fixed; xfail must stay empty"
|
||||
);
|
||||
assert!(
|
||||
[InvariantId::SmoothCoast, InvariantId::NoDrop]
|
||||
.iter()
|
||||
.all(|id| jerk.invariants.contains(id)),
|
||||
"the promoted invariants must remain in the pass set"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expected_profiles_agree_with_env() {
|
||||
for cell in CELLS {
|
||||
assert_eq!(
|
||||
cell.expected,
|
||||
derive_expected(cell.env),
|
||||
"{}: expected profile drifted from its env",
|
||||
cell.id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invariant_lists_are_coherent() {
|
||||
for cell in CELLS {
|
||||
let mut seen = HashSet::new();
|
||||
for id in cell.invariants {
|
||||
assert!(seen.insert(id), "{}: duplicate {}", cell.id, id.as_str());
|
||||
}
|
||||
// Consistency invariants match the forced mode.
|
||||
assert_eq!(
|
||||
cell.invariants.contains(&InvariantId::ConsW),
|
||||
cell.expected.mode == "wheel",
|
||||
"{}: I-CONS-W ⇔ forced wheel",
|
||||
cell.id
|
||||
);
|
||||
assert_eq!(
|
||||
cell.invariants.contains(&InvariantId::ConsA),
|
||||
cell.expected.mode != "wheel",
|
||||
"{}: I-CONS-A ⇔ not forced wheel",
|
||||
cell.id
|
||||
);
|
||||
// Mux over-scroll bound only makes sense on the remuxed class,
|
||||
// and only on its accel-free G9 gestures.
|
||||
if cell.invariants.contains(&InvariantId::MuxNoOver) {
|
||||
assert!(
|
||||
matches!(
|
||||
cell.gesture,
|
||||
GestureId::G9aMuxSingles | GestureId::G9bMuxBatch
|
||||
) && cell.env.iter().any(|(k, _)| *k == "TMUX"),
|
||||
"{}: I-MUX-NO-OVER outside the mux/G9 envelope",
|
||||
cell.id
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sessions_and_gestures_pair_correctly() {
|
||||
for cell in CELLS {
|
||||
// TMUX ⇒ the conservative remuxed profile.
|
||||
if cell.env.iter().any(|(k, _)| *k == "TMUX") {
|
||||
assert_eq!(
|
||||
(cell.expected.ept, cell.expected.wheel_lpt),
|
||||
(1, 1),
|
||||
"{}",
|
||||
cell.id
|
||||
);
|
||||
}
|
||||
// Streaming sessions exist exactly for the mid-stream gesture;
|
||||
// the bottom-pin matters exactly for the overscroll gesture.
|
||||
assert_eq!(
|
||||
cell.session == SessionKind::Streaming,
|
||||
cell.gesture == GestureId::G8MidStreamTrain,
|
||||
"{}",
|
||||
cell.id
|
||||
);
|
||||
assert_eq!(
|
||||
cell.session == SessionKind::BottomPinned,
|
||||
cell.gesture == GestureId::G7Overscroll,
|
||||
"{}",
|
||||
cell.id
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
//! Gesture step tables G1–G11: the timed SGR wheel-report shapes every
|
||||
//! matrix cell replays.
|
||||
//!
|
||||
//! A gesture is a `&'static [WheelStep]`; the A13 runner emits each step's
|
||||
//! report after sleeping `pre_delay_ms` (never before the first step, never
|
||||
//! after the last — the `send_wheel_sequence` contract in the pager's
|
||||
//! `tests/pty_e2e/scroll.rs`). Delays are HOST-side lower bounds: scheduler
|
||||
//! jitter can only stretch gaps, which is why the invariant suite
|
||||
//! ([`super::invariants`]) judges timing from the recorder's own clock.
|
||||
//!
|
||||
//! Timing thresholds below mirror the pager's `src/input/mouse.rs` (the
|
||||
//! harness deliberately has no pager dependency — same drift-tripwire
|
||||
//! stance as [`super::log`]): a table's shape is meaningful only relative
|
||||
//! to those constants, e.g. G2's 50ms notch gap sits under `STREAM_GAP_MS`
|
||||
//! (one stream) but over `WHEEL_TICK_DETECT_MAX_MS` (no wheel promotion of
|
||||
//! the train as one tick).
|
||||
|
||||
use crate::scripted::{SGR_SCROLL_DOWN, SGR_SCROLL_UP};
|
||||
|
||||
/// Mirror of `mouse.rs` `REDRAW_CADENCE_MS`: minimum flush spacing.
|
||||
pub const REDRAW_CADENCE_MS: u64 = 16;
|
||||
/// Mirror of `mouse.rs` `STREAM_GAP_MS`: idle gap that finalizes a stream.
|
||||
pub const STREAM_GAP_MS: u64 = 80;
|
||||
/// Mirror of `mouse.rs` `DEFAULT_WHEEL_TICK_DETECT_MAX_MS`: an ept≥2 stream
|
||||
/// promotes to wheel only when the first tick completes within this window.
|
||||
pub const WHEEL_TICK_DETECT_MAX_MS: u64 = 12;
|
||||
/// Mirror of `mouse.rs` `ACCEL_MIN_INTERVAL_MS`: sub-6ms inter-event
|
||||
/// intervals are terminal batching artifacts and stay out of the
|
||||
/// accel/detection interval window.
|
||||
pub const ACCEL_MIN_INTERVAL_MS: f64 = 6.0;
|
||||
/// Mirror of `mouse.rs` `DEFAULT_TRACKPAD_ACCEL_MAX`: accel clamp ceiling.
|
||||
pub const TRACKPAD_ACCEL_MAX: f64 = 3.0;
|
||||
/// Mirror of `mouse.rs` `MIN_LINES_PER_WHEEL_STREAM`.
|
||||
pub const MIN_LINES_PER_WHEEL_STREAM: i64 = 1;
|
||||
|
||||
/// One SGR wheel report: sleep `pre_delay_ms`, then emit `button`.
|
||||
///
|
||||
/// `button` is [`SGR_SCROLL_UP`]/[`SGR_SCROLL_DOWN`] — `u16` because those
|
||||
/// harness consts are `u16` (the design sketch said `u8`; deviating keeps
|
||||
/// one shared definition instead of a cast at every emission site).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct WheelStep {
|
||||
/// Host-side sleep before emitting this report (0 for the first step).
|
||||
pub pre_delay_ms: u64,
|
||||
/// SGR wheel button code (64 up / 65 down).
|
||||
pub button: u16,
|
||||
}
|
||||
|
||||
/// `count` same-direction reports, `interval_ms` between consecutive ones.
|
||||
const fn burst<const N: usize>(interval_ms: u64, button: u16) -> [WheelStep; N] {
|
||||
let mut steps = [WheelStep {
|
||||
pre_delay_ms: 0,
|
||||
button,
|
||||
}; N];
|
||||
let mut i = 1;
|
||||
while i < N {
|
||||
steps[i].pre_delay_ms = interval_ms;
|
||||
i += 1;
|
||||
}
|
||||
steps
|
||||
}
|
||||
|
||||
/// Notches of `events_per_notch` back-to-back reports, `notch_gap_ms` apart.
|
||||
const fn notch_train<const N: usize>(
|
||||
events_per_notch: usize,
|
||||
notch_gap_ms: u64,
|
||||
button: u16,
|
||||
) -> [WheelStep; N] {
|
||||
let mut steps = burst::<N>(0, button);
|
||||
let mut i = events_per_notch;
|
||||
while i < N {
|
||||
if i.is_multiple_of(events_per_notch) {
|
||||
steps[i].pre_delay_ms = notch_gap_ms;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
steps
|
||||
}
|
||||
|
||||
/// G1 single notch, ept=3 brands: 3 back-to-back reports (first tick lands
|
||||
/// inside the 12ms window → wheel promotion in Auto mode).
|
||||
pub const G1_NOTCH_EPT3: [WheelStep; 3] = burst::<3>(0, SGR_SCROLL_UP);
|
||||
/// G1 single notch, ept=1 brands (iTerm2/zed/vscode/mux): one report.
|
||||
pub const G1_NOTCH_EPT1: [WheelStep; 1] = burst::<1>(0, SGR_SCROLL_UP);
|
||||
/// G2 notch train: 5 notches 50ms apart (< `STREAM_GAP_MS` → one stream).
|
||||
pub const G2_NOTCH_TRAIN_EPT3: [WheelStep; 15] = notch_train::<15>(3, 50, SGR_SCROLL_UP);
|
||||
pub const G2_NOTCH_TRAIN_EPT1: [WheelStep; 5] = burst::<5>(50, SGR_SCROLL_UP);
|
||||
/// G3 flood: 60 back-to-back reports (cap/pacing exercise — A4 teleport).
|
||||
pub const G3_FLOOD: [WheelStep; 60] = burst::<60>(0, SGR_SCROLL_UP);
|
||||
/// G4 jerk repro: a 3-event anti-promotion head at 8ms, 57 dense reports,
|
||||
/// then a decelerating 6-event tail (gaps growing 40→70ms, all under the
|
||||
/// 80ms stream gap).
|
||||
///
|
||||
/// Two shape details make the repro real under a PTY (verified against the
|
||||
/// live recorder):
|
||||
/// - **The head.** Back-to-back writes arrive batched, so a fully dense
|
||||
/// burst completes its first ept=3 tick inside the 12ms window and
|
||||
/// promotes to WHEEL pricing — which never re-prices at finalize and
|
||||
/// never jerks. The 8ms head lands the first tick past the window, so
|
||||
/// the stream stays Unknown (priced ~1 line/event, accel window seeded
|
||||
/// in the fast band).
|
||||
/// - **The 40ms+ tail gaps.** They open cadence slots with no new events
|
||||
/// while the dense backlog is still draining: capped `events_since_flush
|
||||
/// == 0` coast flushes (the I-SMOOTH-COAST signature). Tighter gaps ride
|
||||
/// every slot and mask the coast.
|
||||
///
|
||||
/// At the gap finalize the Unknown→Trackpad re-price (accel-weighted,
|
||||
/// ~2.5× the mid-stream pricing) then bursts one more capped flush and
|
||||
/// drops the rest — the I-NO-DROP half of the jerk (xfail cell until the
|
||||
/// finalize-decel fix).
|
||||
pub const G4_JERK: [WheelStep; 66] = {
|
||||
let mut steps = burst::<66>(0, SGR_SCROLL_UP);
|
||||
steps[1].pre_delay_ms = 8;
|
||||
steps[2].pre_delay_ms = 8;
|
||||
let tail = [40, 44, 50, 55, 60, 70];
|
||||
let mut i = 0;
|
||||
while i < tail.len() {
|
||||
steps[60 + i].pre_delay_ms = tail[i];
|
||||
i += 1;
|
||||
}
|
||||
steps
|
||||
};
|
||||
/// G5 ghostty dup: 10 notches 60ms apart, each report duplicated 4ms later
|
||||
/// (ghostty emits ≥2 SGR reports per physical notch ~4ms apart). The 4ms
|
||||
/// dups sit under `ACCEL_MIN_INTERVAL_MS` and must stay out of the
|
||||
/// interval window — the I-ACCEL G5 clause.
|
||||
pub const G5_GHOSTTY_DUP: [WheelStep; 20] = {
|
||||
let mut steps = burst::<20>(0, SGR_SCROLL_UP);
|
||||
let mut i = 1;
|
||||
while i < 20 {
|
||||
steps[i].pre_delay_ms = if i % 2 == 1 { 4 } else { 60 };
|
||||
i += 1;
|
||||
}
|
||||
steps
|
||||
};
|
||||
/// G6 flip: 10×8ms up then 10×8ms down — the direction flip finalizes
|
||||
/// stream 1 and opens stream 2 at the same instant (two streams).
|
||||
pub const G6_FLIP: [WheelStep; 20] = {
|
||||
let mut steps = burst::<20>(8, SGR_SCROLL_UP);
|
||||
let mut i = 10;
|
||||
while i < 20 {
|
||||
steps[i].button = SGR_SCROLL_DOWN;
|
||||
i += 1;
|
||||
}
|
||||
steps
|
||||
};
|
||||
/// G7 overscroll: bottom-pinned 10×8ms down (viewport must clamp — the
|
||||
/// harness-side I-SCREEN check) then 3 up (must move again). Two streams.
|
||||
pub const G7_OVERSCROLL: [WheelStep; 13] = {
|
||||
let mut steps = burst::<13>(8, SGR_SCROLL_DOWN);
|
||||
let mut i = 10;
|
||||
while i < 13 {
|
||||
steps[i].button = SGR_SCROLL_UP;
|
||||
i += 1;
|
||||
}
|
||||
steps
|
||||
};
|
||||
/// G9a mux re-chunk 1:1: 8 single reports 55ms apart (tmux re-emitting one
|
||||
/// event per notch; 55ms > the 30ms ept=1 trackpad-detect window → never
|
||||
/// promotes to trackpad mid-stream).
|
||||
pub const G9A_MUX_SINGLES: [WheelStep; 8] = burst::<8>(55, SGR_SCROLL_UP);
|
||||
/// G9b mux re-chunk batch: 8 notches 55ms apart × 3 back-to-back events
|
||||
/// (tmux passing through an inner ept=3 chunking, re-timed).
|
||||
pub const G9B_MUX_BATCH: [WheelStep; 24] = notch_train::<24>(3, 55, SGR_SCROLL_UP);
|
||||
/// G10 ambiguous slow roll: 12 reports 40ms apart — inside the vscode-embed
|
||||
/// 60ms trackpad-detect window, outside the default 30ms one.
|
||||
pub const G10_AMBIGUOUS_SLOW: [WheelStep; 12] = burst::<12>(40, SGR_SCROLL_UP);
|
||||
/// G11 carry: one notch, a 120ms wait (> `STREAM_GAP_MS` → finalize), one
|
||||
/// notch — the sub-line carry handoff across same-direction streams.
|
||||
pub const G11_CARRY_EPT3: [WheelStep; 6] = notch_train::<6>(3, 120, SGR_SCROLL_UP);
|
||||
pub const G11_CARRY_EPT1: [WheelStep; 2] = burst::<2>(120, SGR_SCROLL_UP);
|
||||
|
||||
/// Gesture identifier a [`super::cells::MatrixCell`] references.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum GestureId {
|
||||
G1Notch,
|
||||
G2NotchTrain,
|
||||
G3Flood,
|
||||
G4Jerk,
|
||||
G5GhosttyDup,
|
||||
G6Flip,
|
||||
G7Overscroll,
|
||||
/// G2's step table replayed while the turn is still streaming
|
||||
/// (`SessionKind::Streaming`); the shape lives in the session, not here.
|
||||
G8MidStreamTrain,
|
||||
G9aMuxSingles,
|
||||
G9bMuxBatch,
|
||||
G10AmbiguousSlow,
|
||||
G11Carry,
|
||||
}
|
||||
|
||||
impl GestureId {
|
||||
/// Every gesture, for exhaustive table sweeps (tests, the A13 runner).
|
||||
pub const ALL: [GestureId; 12] = [
|
||||
GestureId::G1Notch,
|
||||
GestureId::G2NotchTrain,
|
||||
GestureId::G3Flood,
|
||||
GestureId::G4Jerk,
|
||||
GestureId::G5GhosttyDup,
|
||||
GestureId::G6Flip,
|
||||
GestureId::G7Overscroll,
|
||||
GestureId::G8MidStreamTrain,
|
||||
GestureId::G9aMuxSingles,
|
||||
GestureId::G9bMuxBatch,
|
||||
GestureId::G10AmbiguousSlow,
|
||||
GestureId::G11Carry,
|
||||
];
|
||||
|
||||
/// Step table for this gesture on a brand with `ept` events per notch.
|
||||
/// Only the notch-based gestures (G1/G2/G8/G11) vary by class; the rest
|
||||
/// are fixed event shapes (G9b is deliberately 3-per-notch even on the
|
||||
/// ept=1 mux profile — it simulates the mux passing re-chunked input).
|
||||
pub fn steps(self, ept: u16) -> &'static [WheelStep] {
|
||||
let ept3 = ept >= 2;
|
||||
match self {
|
||||
GestureId::G1Notch => {
|
||||
if ept3 {
|
||||
&G1_NOTCH_EPT3
|
||||
} else {
|
||||
&G1_NOTCH_EPT1
|
||||
}
|
||||
}
|
||||
GestureId::G2NotchTrain | GestureId::G8MidStreamTrain => {
|
||||
if ept3 {
|
||||
&G2_NOTCH_TRAIN_EPT3
|
||||
} else {
|
||||
&G2_NOTCH_TRAIN_EPT1
|
||||
}
|
||||
}
|
||||
GestureId::G3Flood => &G3_FLOOD,
|
||||
GestureId::G4Jerk => &G4_JERK,
|
||||
GestureId::G5GhosttyDup => &G5_GHOSTTY_DUP,
|
||||
GestureId::G6Flip => &G6_FLIP,
|
||||
GestureId::G7Overscroll => &G7_OVERSCROLL,
|
||||
GestureId::G9aMuxSingles => &G9A_MUX_SINGLES,
|
||||
GestureId::G9bMuxBatch => &G9B_MUX_BATCH,
|
||||
GestureId::G10AmbiguousSlow => &G10_AMBIGUOUS_SLOW,
|
||||
GestureId::G11Carry => {
|
||||
if ept3 {
|
||||
&G11_CARRY_EPT3
|
||||
} else {
|
||||
&G11_CARRY_EPT1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Streams (finalize records) this gesture produces: 1, plus one per
|
||||
/// direction flip or >`STREAM_GAP_MS` intra-gesture pause. The A13
|
||||
/// runner's `wait_for_finalize_count` target.
|
||||
pub fn expected_streams(self) -> usize {
|
||||
match self {
|
||||
GestureId::G6Flip | GestureId::G7Overscroll | GestureId::G11Carry => 2,
|
||||
_ => 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `(up, down)` report counts — direction-sum test primitive.
|
||||
pub fn direction_counts(steps: &[WheelStep]) -> (usize, usize) {
|
||||
let up = steps.iter().filter(|s| s.button == SGR_SCROLL_UP).count();
|
||||
(up, steps.len() - up)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Streams split on gaps > STREAM_GAP_MS or direction flips — recompute
|
||||
/// from the table and compare to the declared `expected_streams`.
|
||||
fn streams_in(steps: &[WheelStep]) -> usize {
|
||||
let mut streams = 1;
|
||||
for pair in steps.windows(2) {
|
||||
if pair[1].pre_delay_ms > STREAM_GAP_MS || pair[1].button != pair[0].button {
|
||||
streams += 1;
|
||||
}
|
||||
}
|
||||
streams
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_counts_and_direction_sums() {
|
||||
assert_eq!(G1_NOTCH_EPT3.len(), 3);
|
||||
assert_eq!(G1_NOTCH_EPT1.len(), 1);
|
||||
assert_eq!(G2_NOTCH_TRAIN_EPT3.len(), 15);
|
||||
assert_eq!(G2_NOTCH_TRAIN_EPT1.len(), 5);
|
||||
assert_eq!(G3_FLOOD.len(), 60);
|
||||
assert_eq!(G4_JERK.len(), 66);
|
||||
assert_eq!(G5_GHOSTTY_DUP.len(), 20);
|
||||
assert_eq!(G9B_MUX_BATCH.len(), 24);
|
||||
assert_eq!(G10_AMBIGUOUS_SLOW.len(), 12);
|
||||
|
||||
assert_eq!(direction_counts(&G3_FLOOD), (60, 0));
|
||||
assert_eq!(direction_counts(&G6_FLIP), (10, 10), "flip nets to zero");
|
||||
assert_eq!(direction_counts(&G7_OVERSCROLL), (3, 10));
|
||||
assert_eq!(direction_counts(&G9A_MUX_SINGLES), (8, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notch_structure_and_gaps() {
|
||||
// G2/ept3: notch starts every 3 events carry the 50ms gap, intra-notch 0.
|
||||
for (i, step) in G2_NOTCH_TRAIN_EPT3.iter().enumerate() {
|
||||
let expected = if i > 0 && i % 3 == 0 { 50 } else { 0 };
|
||||
assert_eq!(step.pre_delay_ms, expected, "G2 ept3 step {i}");
|
||||
}
|
||||
// G9b: same shape at 55ms — under the 80ms gap, one stream.
|
||||
let notch_gaps = G9B_MUX_BATCH
|
||||
.iter()
|
||||
.filter(|s| s.pre_delay_ms == 55)
|
||||
.count();
|
||||
assert_eq!(notch_gaps, 7, "8 notches → 7 inter-notch gaps");
|
||||
// G5: dup 4ms after each notch head, notch heads 60ms apart.
|
||||
for (i, step) in G5_GHOSTTY_DUP.iter().enumerate() {
|
||||
let expected = if i == 0 {
|
||||
0
|
||||
} else if i % 2 == 1 {
|
||||
4
|
||||
} else {
|
||||
60
|
||||
};
|
||||
assert_eq!(step.pre_delay_ms, expected, "G5 step {i}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jerk_head_blocks_promotion_and_tail_decays_monotonically() {
|
||||
// Anti-promotion head: the first ept=3 tick must complete strictly
|
||||
// after the 12ms wheel-promotion window even with zero jitter
|
||||
// (sleeps only stretch), or PTY batching wheel-promotes the burst
|
||||
// and the finalize re-price under test never happens.
|
||||
let head_span: u64 = G4_JERK[..3].iter().map(|s| s.pre_delay_ms).sum();
|
||||
assert!(head_span > WHEEL_TICK_DETECT_MAX_MS);
|
||||
|
||||
let tail: Vec<u64> = G4_JERK[60..].iter().map(|s| s.pre_delay_ms).collect();
|
||||
assert_eq!(tail, vec![40, 44, 50, 55, 60, 70]);
|
||||
assert!(
|
||||
tail.windows(2).all(|w| w[0] < w[1]),
|
||||
"strictly decelerating"
|
||||
);
|
||||
// Coast window: every tail gap opens ≥2 empty 16ms cadence slots so
|
||||
// the dense backlog drains as events_since_flush == 0 flushes.
|
||||
assert!(tail.iter().all(|&gap| gap >= 2 * REDRAW_CADENCE_MS));
|
||||
assert!(G4_JERK[3..60].iter().all(|s| s.pre_delay_ms == 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delays_agree_with_stream_gap_thresholds() {
|
||||
// Single-stream gestures never pause past the 80ms finalize gap and
|
||||
// never flip; multi-stream ones split exactly as declared.
|
||||
for gesture in GestureId::ALL {
|
||||
for ept in [1u16, 3] {
|
||||
let steps = gesture.steps(ept);
|
||||
assert!(!steps.is_empty());
|
||||
assert_eq!(steps[0].pre_delay_ms, 0, "{gesture:?} first step");
|
||||
assert_eq!(
|
||||
streams_in(steps),
|
||||
gesture.expected_streams(),
|
||||
"{gesture:?} ept={ept}: table shape vs declared stream count"
|
||||
);
|
||||
}
|
||||
}
|
||||
// The G11 pause is what splits it: strictly past the finalize gap.
|
||||
assert!(G11_CARRY_EPT3[3].pre_delay_ms > STREAM_GAP_MS);
|
||||
assert!(G11_CARRY_EPT1[1].pre_delay_ms > STREAM_GAP_MS);
|
||||
// G1/ept3 is a first tick inside the wheel-promotion window.
|
||||
let g1_span: u64 = G1_NOTCH_EPT3.iter().map(|s| s.pre_delay_ms).sum();
|
||||
assert!(g1_span <= WHEEL_TICK_DETECT_MAX_MS);
|
||||
// G5's dup spacing must sit under the interval-window floor.
|
||||
assert!((G5_GHOSTTY_DUP[1].pre_delay_ms as f64) < ACCEL_MIN_INTERVAL_MS);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,800 @@
|
||||
//! The invariant suite: predicates over grouped `KIGI_SCROLL_LOG` streams.
|
||||
//!
|
||||
//! Stall-safety: every timing predicate reads the RECORDER's clock
|
||||
//! (`ts_ms`, `ms_since_prev_flush`, `avg_interval_ms`), never the test
|
||||
//! process's — CI load can stretch host-side gesture delays but can only
|
||||
//! ever *widen* the producer-measured spacings, so no invariant here can
|
||||
//! false-fail on a loaded machine.
|
||||
//!
|
||||
//! Two invariants are declared here but checked by the A13 matrix runner,
|
||||
//! not this module: [`InvariantId::Screen`] (the viewport visibly
|
||||
//! moved/clamped — needs `PtyHarness` marker positions) and
|
||||
//! [`InvariantId::Quiet`] (no repaint churn after finalize — needs the
|
||||
//! harness frame watermark). They exist in the id enum so cells can declare
|
||||
//! them and the runner can route by [`InvariantId::is_log_side`];
|
||||
//! [`check_log_invariant`] panics if asked to evaluate them.
|
||||
|
||||
use super::cells::ExpectedProfile;
|
||||
use super::gestures::{
|
||||
ACCEL_MIN_INTERVAL_MS, MIN_LINES_PER_WHEEL_STREAM, REDRAW_CADENCE_MS, TRACKPAD_ACCEL_MAX,
|
||||
};
|
||||
use super::log::{ScrollLogLine, StreamGroup};
|
||||
|
||||
/// Float slack for f32-serialized fields (accel/speed/carry comparisons).
|
||||
const F32_TOLERANCE: f64 = 0.01;
|
||||
|
||||
/// Invariant identifier; `as_str` is the design's I-* vocabulary.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum InvariantId {
|
||||
/// I-ORD: `ts_ms` is non-decreasing across the capture (flip
|
||||
/// boundaries legitimately share a timestamp).
|
||||
Ord,
|
||||
/// I-CAP: every record's `|flushed| ≤ cap` — the A4 teleport guard.
|
||||
Cap,
|
||||
/// I-DROP-EQ: finalize carries `dropped == backlog_after` (the producer
|
||||
/// constructs it that way; a mismatch is producer drift).
|
||||
DropEq,
|
||||
/// I-CADENCE: intra-stream flush spacings ≥ `REDRAW_CADENCE_MS − 1` —
|
||||
/// the A2 busy-spin guard. Skips `promotion`-triggered records (they
|
||||
/// flush immediately by design) and `finalize` records (the finalize
|
||||
/// flush deliberately ignores the cadence gate — `finalize_stream_at`
|
||||
/// in `mouse.rs` — and a flip finalize can land mid-slot; a gap
|
||||
/// finalize is ≥80ms anyway, so nothing real is lost). The design
|
||||
/// sketch listed only the promotion skip; the finalize skip is the
|
||||
/// code-verified correction.
|
||||
Cadence,
|
||||
/// I-CONS-W: forced-wheel totals are exact —
|
||||
/// `|applied+dropped| == trunc(events × wheel_lpt/ept × speed)` ±1,
|
||||
/// with the `MIN_LINES_PER_WHEEL_STREAM` substitution when the raw
|
||||
/// pricing truncates to zero. Wheel pricing never includes carry.
|
||||
ConsW,
|
||||
/// I-CONS-A: auto/trackpad totals are bounded —
|
||||
/// per-event pricing lies in `[min(wheel_lpt/ept, tp_lpt/3),
|
||||
/// max(wheel_lpt/ept, ACCEL_MAX × tp_lpt/3)] × speed` (trackpad divisor
|
||||
/// is the normalized 3, accel ceiling 3.0 = `trackpad_accel_max`;
|
||||
/// effective accel tops at 2.5 so the bound is loose-but-sound). Checks
|
||||
/// the finalize's `desired` within `[lo, hi]` and the delivered
|
||||
/// `|applied+dropped| ≤ hi`; ±1 slack absorbs carry/trunc.
|
||||
ConsA,
|
||||
/// I-ACCEL: `1.0 ≤ accel ≤ 3.0` on every record, and `avg_interval_ms`,
|
||||
/// when present, is ≥ `ACCEL_MIN_INTERVAL_MS` (6). The G5 clause: even
|
||||
/// ghostty-style 4ms duplicate reports must never drag the average
|
||||
/// under 6 — the producer excludes sub-6ms intervals from the window,
|
||||
/// so a lower value means that artifact guard regressed.
|
||||
Accel,
|
||||
/// I-CARRY: `|carry| < 1.0` everywhere (only sub-line remainders ride
|
||||
/// across streams), and a wheel-kind finalize zeroes it — the NEXT
|
||||
/// `stream_start` must echo `carry == 0`. The log carries no direction,
|
||||
/// but the machine zeroes carry at wheel finalize and resets it on
|
||||
/// direction change, so the next-start check holds for both same- and
|
||||
/// opposite-direction successors (direction-agnostic strengthening of
|
||||
/// the design's same-direction phrasing).
|
||||
Carry,
|
||||
/// I-CFG: the `stream_start` config echo matches the cell's expected
|
||||
/// profile (mode/ept/wheel_lpt/trackpad_lpt/invert/speed) — the env →
|
||||
/// profile plumbing witness.
|
||||
Cfg,
|
||||
/// I-MUX-NO-OVER: delivered total per stream ≤ `events × speed + 1` —
|
||||
/// the conservative remuxed profile (ept=1/wheel_lpt=1) prices at most
|
||||
/// one line per event. Attach only to accel-free gestures (>20ms
|
||||
/// spacing — exactly 20ms still interpolates to 1.6× in the accel band;
|
||||
/// G9's 55ms clears it): a fast trackpad-classified mux stream may
|
||||
/// legitimately exceed it via accel.
|
||||
MuxNoOver,
|
||||
/// I-SMOOTH-COAST: per stream, `Σ|flushed|` over flush-bearing records
|
||||
/// with `events_since_flush == 0` ≤ cap — motion delivered after input
|
||||
/// stopped is at most one capped catch-up. The jerk's coast-drain +
|
||||
/// finalize re-price burst exceeds it (xfail until A13's decel fix).
|
||||
SmoothCoast,
|
||||
/// I-NO-DROP: every finalize has `dropped == 0`. Attach to gestures the
|
||||
/// cap can keep up with; floods legitimately drop.
|
||||
NoDrop,
|
||||
/// I-SCREEN (harness-side, A13): viewport marker delta matches the
|
||||
/// gesture — moved on scroll, clamped at the bottom pin (G7).
|
||||
Screen,
|
||||
/// I-QUIET (harness-side, A13): frame watermark stays put after the
|
||||
/// last finalize — no post-gesture repaint churn (A2's symptom).
|
||||
Quiet,
|
||||
}
|
||||
|
||||
impl InvariantId {
|
||||
/// Design vocabulary label.
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
InvariantId::Ord => "I-ORD",
|
||||
InvariantId::Cap => "I-CAP",
|
||||
InvariantId::DropEq => "I-DROP-EQ",
|
||||
InvariantId::Cadence => "I-CADENCE",
|
||||
InvariantId::ConsW => "I-CONS-W",
|
||||
InvariantId::ConsA => "I-CONS-A",
|
||||
InvariantId::Accel => "I-ACCEL",
|
||||
InvariantId::Carry => "I-CARRY",
|
||||
InvariantId::Cfg => "I-CFG",
|
||||
InvariantId::MuxNoOver => "I-MUX-NO-OVER",
|
||||
InvariantId::SmoothCoast => "I-SMOOTH-COAST",
|
||||
InvariantId::NoDrop => "I-NO-DROP",
|
||||
InvariantId::Screen => "I-SCREEN",
|
||||
InvariantId::Quiet => "I-QUIET",
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether [`check_log_invariant`] can evaluate this id from the log
|
||||
/// alone. `false` = harness-side (screen/frame state), owned by the
|
||||
/// A13 runner.
|
||||
pub fn is_log_side(self) -> bool {
|
||||
!matches!(self, InvariantId::Screen | InvariantId::Quiet)
|
||||
}
|
||||
}
|
||||
|
||||
/// Verdict of one invariant over one cell's captured streams.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum InvariantResult {
|
||||
Pass,
|
||||
Violated { detail: String },
|
||||
}
|
||||
|
||||
impl InvariantResult {
|
||||
pub fn is_pass(&self) -> bool {
|
||||
matches!(self, InvariantResult::Pass)
|
||||
}
|
||||
}
|
||||
|
||||
fn violated(detail: String) -> InvariantResult {
|
||||
InvariantResult::Violated { detail }
|
||||
}
|
||||
|
||||
/// All records of all groups, in capture order.
|
||||
fn records<'a>(groups: &'a [StreamGroup<'a>]) -> impl Iterator<Item = &'a ScrollLogLine> {
|
||||
groups.iter().flat_map(|g| {
|
||||
std::iter::once(g.start)
|
||||
.chain(g.flushes.iter().copied())
|
||||
.chain(g.finalize)
|
||||
})
|
||||
}
|
||||
|
||||
/// Signed delivered total of a finalized stream: applied + discarded.
|
||||
fn delivered(finalize: &ScrollLogLine) -> i64 {
|
||||
finalize.applied_total + finalize.dropped.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Evaluate one log-side invariant. Panics on harness-side ids
|
||||
/// ([`InvariantId::is_log_side`] == false) — routing those here is a
|
||||
/// runner bug, not a cell verdict.
|
||||
pub fn check_log_invariant(
|
||||
id: InvariantId,
|
||||
expected: &ExpectedProfile,
|
||||
groups: &[StreamGroup<'_>],
|
||||
) -> InvariantResult {
|
||||
match id {
|
||||
InvariantId::Ord => check_ord(groups),
|
||||
InvariantId::Cap => check_cap(groups),
|
||||
InvariantId::DropEq => check_drop_eq(groups),
|
||||
InvariantId::Cadence => check_cadence(groups),
|
||||
InvariantId::ConsW => check_cons_w(expected, groups),
|
||||
InvariantId::ConsA => check_cons_a(expected, groups),
|
||||
InvariantId::Accel => check_accel(groups),
|
||||
InvariantId::Carry => check_carry(groups),
|
||||
InvariantId::Cfg => check_cfg(expected, groups),
|
||||
InvariantId::MuxNoOver => check_mux_no_over(expected, groups),
|
||||
InvariantId::SmoothCoast => check_smooth_coast(groups),
|
||||
InvariantId::NoDrop => check_no_drop(groups),
|
||||
InvariantId::Screen | InvariantId::Quiet => panic!(
|
||||
"{} is harness-side (needs PtyHarness screen/frame state); the A13 matrix \
|
||||
runner checks it — route by InvariantId::is_log_side",
|
||||
id.as_str()
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn check_ord(groups: &[StreamGroup<'_>]) -> InvariantResult {
|
||||
let mut prev = f64::NEG_INFINITY;
|
||||
for rec in records(groups) {
|
||||
if rec.ts_ms < prev {
|
||||
return violated(format!(
|
||||
"ts_ms went backwards: {} after {prev} ({} record)",
|
||||
rec.ts_ms, rec.evt
|
||||
));
|
||||
}
|
||||
prev = rec.ts_ms;
|
||||
}
|
||||
InvariantResult::Pass
|
||||
}
|
||||
|
||||
fn check_cap(groups: &[StreamGroup<'_>]) -> InvariantResult {
|
||||
for rec in records(groups) {
|
||||
if rec.flushed.abs() > rec.cap {
|
||||
return violated(format!(
|
||||
"flushed {} exceeds cap {} at ts_ms={} (trigger {})",
|
||||
rec.flushed, rec.cap, rec.ts_ms, rec.trigger
|
||||
));
|
||||
}
|
||||
}
|
||||
InvariantResult::Pass
|
||||
}
|
||||
|
||||
fn check_drop_eq(groups: &[StreamGroup<'_>]) -> InvariantResult {
|
||||
for group in groups {
|
||||
let Some(fin) = group.finalize else { continue };
|
||||
match fin.dropped {
|
||||
Some(dropped) if dropped == fin.backlog_after => {}
|
||||
Some(dropped) => {
|
||||
return violated(format!(
|
||||
"finalize at ts_ms={} has dropped={dropped} but backlog_after={}",
|
||||
fin.ts_ms, fin.backlog_after
|
||||
));
|
||||
}
|
||||
None => {
|
||||
return violated(format!("finalize at ts_ms={} lacks dropped", fin.ts_ms));
|
||||
}
|
||||
}
|
||||
}
|
||||
InvariantResult::Pass
|
||||
}
|
||||
|
||||
fn check_cadence(groups: &[StreamGroup<'_>]) -> InvariantResult {
|
||||
// A stream's first flush-bearing record is skipped: its spacing is
|
||||
// global (measured from the previous stream — see
|
||||
// `intra_stream_flush_spacings_ms`).
|
||||
let floor = (REDRAW_CADENCE_MS - 1) as f64;
|
||||
for group in groups {
|
||||
for rec in group.flush_bearing().skip(1) {
|
||||
if rec.trigger == "promotion" || rec.trigger == "finalize" {
|
||||
continue;
|
||||
}
|
||||
if let Some(spacing) = rec.ms_since_prev_flush
|
||||
&& spacing < floor
|
||||
{
|
||||
return violated(format!(
|
||||
"{}ms flush spacing < {floor}ms at ts_ms={} (trigger {})",
|
||||
spacing, rec.ts_ms, rec.trigger
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
InvariantResult::Pass
|
||||
}
|
||||
|
||||
fn check_cons_w(expected: &ExpectedProfile, groups: &[StreamGroup<'_>]) -> InvariantResult {
|
||||
if expected.mode != "wheel" {
|
||||
return violated(format!(
|
||||
"I-CONS-W attached to a mode={} cell (needs forced wheel)",
|
||||
expected.mode
|
||||
));
|
||||
}
|
||||
let rate = f64::from(expected.wheel_lpt) / f64::from(expected.ept.max(1));
|
||||
for group in groups {
|
||||
let Some(fin) = group.finalize else { continue };
|
||||
let raw = (fin.events_total as f64 * rate * f64::from(expected.speed)).trunc() as i64;
|
||||
let want = if fin.events_total > 0 {
|
||||
raw.max(MIN_LINES_PER_WHEEL_STREAM)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let got = delivered(fin).abs();
|
||||
if (got - want).abs() > 1 {
|
||||
return violated(format!(
|
||||
"wheel stream at ts_ms={}: delivered {got} lines for {} events, expected {want}±1",
|
||||
fin.ts_ms, fin.events_total
|
||||
));
|
||||
}
|
||||
}
|
||||
InvariantResult::Pass
|
||||
}
|
||||
|
||||
fn check_cons_a(expected: &ExpectedProfile, groups: &[StreamGroup<'_>]) -> InvariantResult {
|
||||
if expected.mode == "wheel" {
|
||||
return violated("I-CONS-A attached to a forced-wheel cell (use I-CONS-W)".into());
|
||||
}
|
||||
let wheel_rate = f64::from(expected.wheel_lpt) / f64::from(expected.ept.max(1));
|
||||
let tp_rate = f64::from(expected.trackpad_lpt) / 3.0;
|
||||
// Forced trackpad never prices via the wheel table.
|
||||
let (min_rate, max_rate) = if expected.mode == "trackpad" {
|
||||
(tp_rate, TRACKPAD_ACCEL_MAX * tp_rate)
|
||||
} else {
|
||||
(
|
||||
wheel_rate.min(tp_rate),
|
||||
wheel_rate.max(TRACKPAD_ACCEL_MAX * tp_rate),
|
||||
)
|
||||
};
|
||||
for group in groups {
|
||||
let Some(fin) = group.finalize else { continue };
|
||||
let events = fin.events_total as f64;
|
||||
let speed = f64::from(expected.speed);
|
||||
let lo = events * min_rate * speed - 1.0;
|
||||
let hi = events * max_rate * speed + 1.0;
|
||||
let desired = f64::from(fin.desired).abs();
|
||||
if desired < lo || desired > hi {
|
||||
return violated(format!(
|
||||
"stream at ts_ms={}: |desired|={desired:.2} outside [{lo:.2}, {hi:.2}] \
|
||||
for {} events",
|
||||
fin.ts_ms, fin.events_total
|
||||
));
|
||||
}
|
||||
let got = delivered(fin).abs() as f64;
|
||||
if got > hi {
|
||||
return violated(format!(
|
||||
"stream at ts_ms={}: delivered {got} lines > bound {hi:.2} for {} events",
|
||||
fin.ts_ms, fin.events_total
|
||||
));
|
||||
}
|
||||
}
|
||||
InvariantResult::Pass
|
||||
}
|
||||
|
||||
fn check_accel(groups: &[StreamGroup<'_>]) -> InvariantResult {
|
||||
for rec in records(groups) {
|
||||
let accel = f64::from(rec.accel);
|
||||
if !(1.0 - F32_TOLERANCE..=TRACKPAD_ACCEL_MAX + F32_TOLERANCE).contains(&accel) {
|
||||
return violated(format!(
|
||||
"accel {accel} outside [1.0, {TRACKPAD_ACCEL_MAX}] at ts_ms={}",
|
||||
rec.ts_ms
|
||||
));
|
||||
}
|
||||
if let Some(avg) = rec.avg_interval_ms
|
||||
&& avg < ACCEL_MIN_INTERVAL_MS - F32_TOLERANCE
|
||||
{
|
||||
return violated(format!(
|
||||
"avg_interval_ms {avg} < 6 at ts_ms={} — sub-6ms batching artifacts \
|
||||
(ghostty dups) leaked into the interval window",
|
||||
rec.ts_ms
|
||||
));
|
||||
}
|
||||
}
|
||||
InvariantResult::Pass
|
||||
}
|
||||
|
||||
fn check_carry(groups: &[StreamGroup<'_>]) -> InvariantResult {
|
||||
for rec in records(groups) {
|
||||
if f64::from(rec.carry).abs() >= 1.0 {
|
||||
return violated(format!(
|
||||
"|carry| {} ≥ 1.0 at ts_ms={} — whole lines leaked across streams",
|
||||
rec.carry, rec.ts_ms
|
||||
));
|
||||
}
|
||||
}
|
||||
for pair in groups.windows(2) {
|
||||
let Some(fin) = pair[0].finalize else {
|
||||
continue;
|
||||
};
|
||||
if fin.kind == "wheel" && f64::from(pair[1].start.carry).abs() > F32_TOLERANCE {
|
||||
return violated(format!(
|
||||
"stream_start at ts_ms={} carries {} after a wheel finalize (must be 0)",
|
||||
pair[1].start.ts_ms, pair[1].start.carry
|
||||
));
|
||||
}
|
||||
}
|
||||
InvariantResult::Pass
|
||||
}
|
||||
|
||||
fn check_cfg(expected: &ExpectedProfile, groups: &[StreamGroup<'_>]) -> InvariantResult {
|
||||
for group in groups {
|
||||
let start = group.start;
|
||||
let echo = (
|
||||
start.mode.as_deref(),
|
||||
start.ept,
|
||||
start.wheel_lpt,
|
||||
start.trackpad_lpt,
|
||||
start.invert,
|
||||
);
|
||||
let want = (
|
||||
Some(expected.mode),
|
||||
Some(expected.ept),
|
||||
Some(expected.wheel_lpt),
|
||||
Some(expected.trackpad_lpt),
|
||||
Some(expected.invert),
|
||||
);
|
||||
let speed_ok = start
|
||||
.speed
|
||||
.is_some_and(|s| (f64::from(s) - f64::from(expected.speed)).abs() <= F32_TOLERANCE);
|
||||
if echo != want || !speed_ok {
|
||||
return violated(format!(
|
||||
"config echo at ts_ms={} is {:?} speed={:?}, cell expects \
|
||||
mode={} ept={} wheel_lpt={} trackpad_lpt={} invert={} speed={}",
|
||||
start.ts_ms,
|
||||
echo,
|
||||
start.speed,
|
||||
expected.mode,
|
||||
expected.ept,
|
||||
expected.wheel_lpt,
|
||||
expected.trackpad_lpt,
|
||||
expected.invert,
|
||||
expected.speed,
|
||||
));
|
||||
}
|
||||
}
|
||||
InvariantResult::Pass
|
||||
}
|
||||
|
||||
fn check_mux_no_over(expected: &ExpectedProfile, groups: &[StreamGroup<'_>]) -> InvariantResult {
|
||||
for group in groups {
|
||||
let Some(fin) = group.finalize else { continue };
|
||||
let bound = fin.events_total as f64 * f64::from(expected.speed) + 1.0;
|
||||
let got = delivered(fin).abs() as f64;
|
||||
if got > bound {
|
||||
return violated(format!(
|
||||
"remuxed stream at ts_ms={}: delivered {got} lines for {} events \
|
||||
(> {bound:.2} — over-scroll on the conservative mux profile)",
|
||||
fin.ts_ms, fin.events_total
|
||||
));
|
||||
}
|
||||
}
|
||||
InvariantResult::Pass
|
||||
}
|
||||
|
||||
fn check_smooth_coast(groups: &[StreamGroup<'_>]) -> InvariantResult {
|
||||
for group in groups {
|
||||
let coast: i64 = group
|
||||
.flush_bearing()
|
||||
.filter(|rec| rec.events_since_flush == 0)
|
||||
.map(|rec| rec.flushed.abs())
|
||||
.sum();
|
||||
let cap = group
|
||||
.flush_bearing()
|
||||
.map(|rec| rec.cap)
|
||||
.max()
|
||||
.unwrap_or(i64::MAX);
|
||||
if coast > cap {
|
||||
return violated(format!(
|
||||
"stream at ts_ms={}: {coast} coast lines (flushes with no new events) \
|
||||
> cap {cap} — post-input motion beyond one capped catch-up (the jerk)",
|
||||
group.start.ts_ms
|
||||
));
|
||||
}
|
||||
}
|
||||
InvariantResult::Pass
|
||||
}
|
||||
|
||||
fn check_no_drop(groups: &[StreamGroup<'_>]) -> InvariantResult {
|
||||
for group in groups {
|
||||
let Some(fin) = group.finalize else { continue };
|
||||
let dropped = fin.dropped.unwrap_or(0);
|
||||
if dropped != 0 {
|
||||
return violated(format!(
|
||||
"finalize at ts_ms={} dropped {dropped} lines",
|
||||
fin.ts_ms
|
||||
));
|
||||
}
|
||||
}
|
||||
InvariantResult::Pass
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::log::{group_streams, parse_jsonl_str};
|
||||
use super::*;
|
||||
|
||||
// ── JSONL fixture builders ─────────────────────────────────────────
|
||||
// Raw strings through parse_jsonl_str so every fixture also exercises
|
||||
// the wire schema (same stance as log.rs's producer-shaped constants).
|
||||
|
||||
const C1: ExpectedProfile = ExpectedProfile {
|
||||
mode: "auto",
|
||||
ept: 3,
|
||||
wheel_lpt: 3,
|
||||
trackpad_lpt: 3,
|
||||
invert: false,
|
||||
speed: 1.0,
|
||||
};
|
||||
const C1_WHEEL: ExpectedProfile = ExpectedProfile {
|
||||
mode: "wheel",
|
||||
..C1
|
||||
};
|
||||
|
||||
// Floats are formatted with `{:?}` so whole values keep their decimal
|
||||
// point (`32.0`, not `32`) — the mutation `.replace()`s below match on
|
||||
// that spelling, and it mirrors serde_json's f64 output for round
|
||||
// numbers.
|
||||
fn start(ts: f64, carry: f32, mode: &str, speed: f32) -> String {
|
||||
format!(
|
||||
r#"{{"ts_ms":{ts:?},"evt":"stream_start","trigger":"event","kind":"unknown","events_total":0,"events_since_flush":0,"accel":1.0,"desired":0.0,"applied_total":0,"flushed":0,"backlog_after":0,"carry":{carry:?},"cap":25,"mode":"{mode}","ept":3,"wheel_lpt":3,"trackpad_lpt":3,"invert":false,"speed":{speed:?},"viewport_height":50}}"#
|
||||
)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn row(
|
||||
evt: &str,
|
||||
ts: f64,
|
||||
trigger: &str,
|
||||
kind: &str,
|
||||
events: (u64, u64),
|
||||
accel_avg: (f32, Option<f64>),
|
||||
lines: (f32, i64, i64, i64), // desired, applied_total, flushed, backlog_after
|
||||
msf: Option<f64>,
|
||||
dropped: Option<i64>,
|
||||
) -> String {
|
||||
let avg = accel_avg
|
||||
.1
|
||||
.map_or(String::new(), |v| format!(r#""avg_interval_ms":{v:?},"#));
|
||||
let msf = msf.map_or(String::new(), |v| {
|
||||
format!(r#","ms_since_prev_flush":{v:?}"#)
|
||||
});
|
||||
let dropped = dropped.map_or(String::new(), |v| format!(r#","dropped":{v}"#));
|
||||
format!(
|
||||
r#"{{"ts_ms":{ts:?},"evt":"{evt}","trigger":"{trigger}","kind":"{kind}","events_total":{},"events_since_flush":{},{avg}"accel":{:?},"desired":{:?},"applied_total":{},"flushed":{},"backlog_after":{},"carry":0.0,"cap":25{msf}{dropped}}}"#,
|
||||
events.0, events.1, accel_avg.0, lines.0, lines.1, lines.2, lines.3,
|
||||
)
|
||||
}
|
||||
|
||||
fn check(id: InvariantId, expected: &ExpectedProfile, jsonl: &[String]) -> InvariantResult {
|
||||
let raw = jsonl.join("\n");
|
||||
let records = parse_jsonl_str(&raw).expect("fixture must parse");
|
||||
let groups = group_streams(&records).expect("fixture must group");
|
||||
check_log_invariant(id, expected, &groups)
|
||||
}
|
||||
|
||||
fn assert_violated(result: InvariantResult, needle: &str) {
|
||||
match result {
|
||||
InvariantResult::Violated { detail } => {
|
||||
assert!(
|
||||
detail.contains(needle),
|
||||
"detail {detail:?} lacks {needle:?}"
|
||||
);
|
||||
}
|
||||
InvariantResult::Pass => panic!("expected violation mentioning {needle:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Canonical clean capture: a trackpad stream (sub-line carry out), a
|
||||
/// wheel stream (carry zeroed), a third stream echoing that zero.
|
||||
/// One record per line (rustfmt::skip) — reads like the JSONL it builds.
|
||||
#[rustfmt::skip]
|
||||
fn canonical() -> Vec<String> {
|
||||
vec![
|
||||
start(0.0, 0.0, "auto", 1.0),
|
||||
row("flush", 16.0, "event", "trackpad", (6, 6), (1.0, Some(10.0)), (6.4, 6, 6, 0), None, None),
|
||||
row("flush", 32.0, "tick", "trackpad", (9, 3), (1.0, Some(10.0)), (9.4, 9, 3, 0), Some(16.0), None),
|
||||
row("finalize", 120.0, "finalize", "trackpad", (9, 0), (1.0, Some(10.0)), (9.4, 9, 0, 0), Some(88.0), Some(0)),
|
||||
start(500.0, 0.4, "auto", 1.0),
|
||||
row("flush", 505.0, "promotion", "wheel", (3, 3), (1.0, None), (3.0, 3, 3, 0), Some(385.0), None),
|
||||
row("finalize", 600.0, "finalize", "wheel", (3, 0), (1.0, None), (3.0, 3, 0, 0), Some(95.0), Some(0)),
|
||||
start(700.0, 0.0, "auto", 1.0),
|
||||
row("flush", 716.0, "event", "unknown", (6, 6), (1.0, Some(8.0)), (6.2, 6, 6, 0), Some(116.0), None),
|
||||
row("finalize", 800.0, "finalize", "trackpad", (6, 0), (1.0, Some(8.0)), (6.2, 6, 0, 0), Some(84.0), Some(0)),
|
||||
]
|
||||
}
|
||||
|
||||
/// The real G4 signature: coast drain + finalize re-price burst with a
|
||||
/// drop — mid-stream priced as unknown (~1 line/event), re-priced ×2.5
|
||||
/// at the trackpad finalize, backlog beyond one capped flush discarded.
|
||||
#[rustfmt::skip]
|
||||
fn jerk() -> Vec<String> {
|
||||
vec![
|
||||
start(0.0, 0.0, "auto", 1.0),
|
||||
row("flush", 16.0, "event", "unknown", (40, 40), (2.5, Some(6.0)), (40.0, 25, 25, 15), None, None),
|
||||
row("flush", 32.0, "tick", "unknown", (66, 26), (2.5, Some(6.0)), (66.0, 50, 25, 16), Some(16.0), None),
|
||||
row("flush", 48.0, "tick", "unknown", (66, 0), (2.5, Some(6.0)), (66.0, 66, 16, 0), Some(16.0), None),
|
||||
row("finalize", 146.0, "finalize", "trackpad", (66, 0), (2.5, Some(6.0)), (165.0, 91, 25, 74), Some(98.0), Some(74)),
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_log_side_invariant_passes_on_the_canonical_capture() {
|
||||
// All log-side ids except I-CONS-W, which requires a forced-wheel
|
||||
// profile and has its own pass fixture.
|
||||
let fixture = canonical();
|
||||
for id in [
|
||||
InvariantId::Ord,
|
||||
InvariantId::Cap,
|
||||
InvariantId::DropEq,
|
||||
InvariantId::Cadence,
|
||||
InvariantId::ConsA,
|
||||
InvariantId::Accel,
|
||||
InvariantId::Carry,
|
||||
InvariantId::Cfg,
|
||||
InvariantId::MuxNoOver,
|
||||
InvariantId::SmoothCoast,
|
||||
InvariantId::NoDrop,
|
||||
] {
|
||||
let result = check(id, &C1, &fixture);
|
||||
assert!(result.is_pass(), "{} on canonical: {result:?}", id.as_str());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ord_rejects_backwards_timestamps() {
|
||||
let mut fixture = canonical();
|
||||
fixture[2] = fixture[2].replace(r#""ts_ms":32.0"#, r#""ts_ms":12.0"#);
|
||||
assert_violated(check(InvariantId::Ord, &C1, &fixture), "backwards");
|
||||
}
|
||||
|
||||
/// A4 teleport: one flush delivering more than the per-flush cap.
|
||||
#[test]
|
||||
fn cap_rejects_over_cap_flush() {
|
||||
let mut fixture = canonical();
|
||||
fixture[1] = fixture[1].replace(r#""flushed":6"#, r#""flushed":40"#);
|
||||
assert_violated(check(InvariantId::Cap, &C1, &fixture), "exceeds cap");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_eq_rejects_mismatched_finalize_accounting() {
|
||||
let mut fixture = canonical();
|
||||
fixture[3] = fixture[3].replace(r#""backlog_after":0,"#, r#""backlog_after":9,"#);
|
||||
fixture[3] = fixture[3].replace(r#""dropped":0"#, r#""dropped":5"#);
|
||||
assert_violated(check(InvariantId::DropEq, &C1, &fixture), "dropped=5");
|
||||
}
|
||||
|
||||
/// A2 busy-spin: an 8ms tick-flush spacing violates; the same spacing on
|
||||
/// a promotion-triggered record is skipped (promotion flushes bypass the
|
||||
/// cadence gate by design), as are finalize records (flip finalizes).
|
||||
#[test]
|
||||
fn cadence_rejects_sub_16ms_spacing_but_skips_promotion_and_finalize() {
|
||||
let mut fixture = canonical();
|
||||
fixture[2] = fixture[2].replace(
|
||||
r#""ms_since_prev_flush":16.0"#,
|
||||
r#""ms_since_prev_flush":8.0"#,
|
||||
);
|
||||
assert_violated(check(InvariantId::Cadence, &C1, &fixture), "8ms");
|
||||
|
||||
let mut skipped = canonical();
|
||||
skipped[2] = skipped[2]
|
||||
.replace(r#""trigger":"tick""#, r#""trigger":"promotion""#)
|
||||
.replace(
|
||||
r#""ms_since_prev_flush":16.0"#,
|
||||
r#""ms_since_prev_flush":8.0"#,
|
||||
);
|
||||
// Flip-style finalize 8ms after the previous flush: also skipped.
|
||||
skipped[3] = skipped[3].replace(
|
||||
r#""ms_since_prev_flush":88.0"#,
|
||||
r#""ms_since_prev_flush":8.0"#,
|
||||
);
|
||||
assert!(check(InvariantId::Cadence, &C1, &skipped).is_pass());
|
||||
}
|
||||
|
||||
/// A3 under-travel: a forced-wheel stream delivering two lines short.
|
||||
#[test]
|
||||
fn cons_w_exact_totals_with_min_lines_substitution() {
|
||||
// 6 events × (3/3) × 1.0 = 6 lines, delivered exactly; a second
|
||||
// 1-event stream prices to 1.0 → still ≥ the MIN_LINES floor.
|
||||
#[rustfmt::skip]
|
||||
let pass = vec![
|
||||
start(0.0, 0.0, "wheel", 1.0),
|
||||
row("flush", 16.0, "event", "wheel", (6, 6), (1.0, None), (6.0, 6, 6, 0), None, None),
|
||||
row("finalize", 100.0, "finalize", "wheel", (6, 0), (1.0, None), (6.0, 6, 0, 0), Some(84.0), Some(0)),
|
||||
start(300.0, 0.0, "wheel", 1.0),
|
||||
row("finalize", 400.0, "finalize", "wheel", (1, 1), (1.0, None), (1.0, 1, 1, 0), Some(300.0), Some(0)),
|
||||
];
|
||||
assert!(check(InvariantId::ConsW, &C1_WHEEL, &pass).is_pass());
|
||||
|
||||
let mut short = pass.clone();
|
||||
short[1] = short[1].replace(
|
||||
r#""applied_total":6,"flushed":6"#,
|
||||
r#""applied_total":4,"flushed":4"#,
|
||||
);
|
||||
short[2] = short[2].replace(r#""applied_total":6"#, r#""applied_total":4"#);
|
||||
assert_violated(check(InvariantId::ConsW, &C1_WHEEL, &short), "expected 6±1");
|
||||
|
||||
// Attached to a non-wheel cell = cell-table bug, not a pass.
|
||||
assert_violated(check(InvariantId::ConsW, &C1, &pass), "needs forced wheel");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cons_a_bounds_auto_totals() {
|
||||
// 3 events on the C1 profile can never desire 100 lines
|
||||
// (hi = 3 × max(1, 3×1) × 1 + 1 = 10).
|
||||
let mut fixture = canonical();
|
||||
fixture[6] = fixture[6].replace(r#""desired":3.0"#, r#""desired":100.0"#);
|
||||
assert_violated(check(InvariantId::ConsA, &C1, &fixture), "outside");
|
||||
assert_violated(
|
||||
check(InvariantId::ConsA, &C1_WHEEL, &canonical()),
|
||||
"use I-CONS-W",
|
||||
);
|
||||
}
|
||||
|
||||
/// G5 clause: a sub-6ms average means ghostty-style duplicate reports
|
||||
/// leaked into the interval window; out-of-range accel is the same
|
||||
/// regression on the multiplier side.
|
||||
#[test]
|
||||
fn accel_rejects_out_of_band_multiplier_and_sub_6ms_average() {
|
||||
let mut fixture = canonical();
|
||||
fixture[1] = fixture[1].replace(r#""accel":1.0"#, r#""accel":4.5"#);
|
||||
assert_violated(check(InvariantId::Accel, &C1, &fixture), "accel 4.5");
|
||||
|
||||
let mut dup = canonical();
|
||||
dup[1] = dup[1].replace(r#""avg_interval_ms":10.0"#, r#""avg_interval_ms":3.0"#);
|
||||
assert_violated(check(InvariantId::Accel, &C1, &dup), "interval window");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn carry_rejects_whole_line_carry_and_nonzero_start_after_wheel_finalize() {
|
||||
let mut fixture = canonical();
|
||||
fixture[4] = start(500.0, 1.4, "auto", 1.0);
|
||||
assert_violated(check(InvariantId::Carry, &C1, &fixture), "≥ 1.0");
|
||||
|
||||
// Wheel finalize (stream 2) must zero the carry into stream 3.
|
||||
let mut leak = canonical();
|
||||
leak[7] = start(700.0, 0.4, "auto", 1.0);
|
||||
assert_violated(check(InvariantId::Carry, &C1, &leak), "wheel finalize");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cfg_rejects_echo_profile_mismatch() {
|
||||
assert!(check(InvariantId::Cfg, &C1, &canonical()).is_pass());
|
||||
let mut fixture = canonical();
|
||||
fixture[0] = start(0.0, 0.0, "auto", 6.0); // speed echo ≠ expected 1.0
|
||||
assert_violated(check(InvariantId::Cfg, &C1, &fixture), "speed");
|
||||
let expected_wheel = ExpectedProfile {
|
||||
mode: "wheel",
|
||||
..C1
|
||||
};
|
||||
assert_violated(
|
||||
check(InvariantId::Cfg, &expected_wheel, &canonical()),
|
||||
"mode",
|
||||
);
|
||||
}
|
||||
|
||||
/// A4-class over-scroll on the conservative remuxed profile: more
|
||||
/// delivered lines than events at speed 1.0.
|
||||
#[test]
|
||||
fn mux_no_over_rejects_over_delivery() {
|
||||
let mut fixture = canonical();
|
||||
fixture[3] = fixture[3].replace(r#""applied_total":9"#, r#""applied_total":20"#);
|
||||
// Keep DropEq-independent: only this invariant is under test.
|
||||
assert_violated(check(InvariantId::MuxNoOver, &C1, &fixture), "over-scroll");
|
||||
}
|
||||
|
||||
/// The jerk fixture violates exactly the two xfail invariants of
|
||||
/// `c1_auto_g4_jerk_xfail` — and nothing else in the core suite, which
|
||||
/// is what confines the expected failure to those rows.
|
||||
#[test]
|
||||
fn jerk_shape_violates_smooth_coast_and_no_drop_only() {
|
||||
let fixture = jerk();
|
||||
assert_violated(check(InvariantId::SmoothCoast, &C1, &fixture), "coast");
|
||||
assert_violated(check(InvariantId::NoDrop, &C1, &fixture), "dropped 74");
|
||||
for id in [
|
||||
InvariantId::Ord,
|
||||
InvariantId::Cap,
|
||||
InvariantId::DropEq,
|
||||
InvariantId::Cadence,
|
||||
InvariantId::ConsA,
|
||||
InvariantId::Accel,
|
||||
InvariantId::Carry,
|
||||
InvariantId::Cfg,
|
||||
] {
|
||||
let result = check(id, &C1, &fixture);
|
||||
assert!(result.is_pass(), "{} on jerk: {result:?}", id.as_str());
|
||||
}
|
||||
}
|
||||
|
||||
/// One capped catch-up flush after input stops is legitimate (that is
|
||||
/// the finalize contract) — coast ≤ cap passes.
|
||||
#[test]
|
||||
fn smooth_coast_allows_a_single_capped_catchup() {
|
||||
#[rustfmt::skip]
|
||||
let fixture = vec![
|
||||
start(0.0, 0.0, "auto", 1.0),
|
||||
row("flush", 16.0, "event", "unknown", (30, 30), (1.0, Some(6.0)), (30.0, 25, 25, 5), None, None),
|
||||
row("finalize", 100.0, "finalize", "trackpad", (30, 0), (1.0, Some(6.0)), (30.0, 30, 5, 0), Some(84.0), Some(0)),
|
||||
];
|
||||
assert!(check(InvariantId::SmoothCoast, &C1, &fixture).is_pass());
|
||||
assert!(check(InvariantId::NoDrop, &C1, &fixture).is_pass());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "harness-side")]
|
||||
fn harness_side_ids_panic_in_the_log_checker() {
|
||||
let _ = check_log_invariant(InvariantId::Screen, &C1, &[]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn log_side_partition_matches_the_a13_split() {
|
||||
for id in [InvariantId::Screen, InvariantId::Quiet] {
|
||||
assert!(!id.is_log_side(), "{}", id.as_str());
|
||||
}
|
||||
for id in [
|
||||
InvariantId::Ord,
|
||||
InvariantId::Cap,
|
||||
InvariantId::DropEq,
|
||||
InvariantId::Cadence,
|
||||
InvariantId::ConsW,
|
||||
InvariantId::ConsA,
|
||||
InvariantId::Accel,
|
||||
InvariantId::Carry,
|
||||
InvariantId::Cfg,
|
||||
InvariantId::MuxNoOver,
|
||||
InvariantId::SmoothCoast,
|
||||
InvariantId::NoDrop,
|
||||
] {
|
||||
assert!(id.is_log_side(), "{}", id.as_str());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,502 @@
|
||||
//! `KIGI_SCROLL_LOG` JSONL parsing, per-stream grouping, and finalize
|
||||
//! synchronization.
|
||||
//!
|
||||
//! Wire schema source of truth: the pager's `ScrollLogRecord` in
|
||||
//! `kigi-tui/src/input/scroll_log.rs`. [`ScrollLogLine`] mirrors it
|
||||
//! field-for-field with every always-emitted field **required** (see the
|
||||
//! module docs in [`super`] for the drift-tripwire rationale).
|
||||
|
||||
use std::path::Path;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use serde::Deserialize;
|
||||
|
||||
/// `evt` value of a stream-start record (config echo rides these only).
|
||||
pub const EVT_STREAM_START: &str = "stream_start";
|
||||
/// `evt` value of a mid-stream line-delivering flush record.
|
||||
pub const EVT_FLUSH: &str = "flush";
|
||||
/// `evt` value of a stream-finalize record (`dropped` rides these only).
|
||||
pub const EVT_FINALIZE: &str = "finalize";
|
||||
|
||||
/// One parsed flight-recorder line.
|
||||
///
|
||||
/// Field names and required/optional split mirror the producer's
|
||||
/// `ScrollLogRecord` (`scroll_log.rs`): the producer always emits the
|
||||
/// non-`Option` fields, `#[serde(skip_serializing_if)]`s the `Option`
|
||||
/// bookkeeping fields, and `#[serde(flatten)]`s the config echo onto
|
||||
/// `stream_start` records only. Unknown fields are tolerated (additive
|
||||
/// producer changes must not break older matrix code); missing required
|
||||
/// fields fail loudly with the line number via [`parse_jsonl`].
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct ScrollLogLine {
|
||||
/// Monotonic ms since recorder start (the state machine's timeline).
|
||||
pub ts_ms: f64,
|
||||
/// Record type: [`EVT_STREAM_START`] | [`EVT_FLUSH`] | [`EVT_FINALIZE`].
|
||||
pub evt: String,
|
||||
/// Emitting code path: `event` | `tick` | `promotion` | `finalize`.
|
||||
pub trigger: String,
|
||||
/// Stream classification (`unknown` until promotion/finalize).
|
||||
pub kind: String,
|
||||
/// Events accumulated in the stream so far.
|
||||
pub events_total: u64,
|
||||
/// Arrivals since the last logged flush/finalize of this stream.
|
||||
pub events_since_flush: u64,
|
||||
/// Acceleration multiplier in effect.
|
||||
pub accel: f32,
|
||||
/// Fractional target lines (post accel/speed multipliers, carry included).
|
||||
pub desired: f32,
|
||||
/// Whole lines delivered for this stream so far (post-flush).
|
||||
pub applied_total: i64,
|
||||
/// Lines this record's flush delivered (0 on stream_start).
|
||||
pub flushed: i64,
|
||||
/// Whole-line backlog remaining after this record's flush.
|
||||
pub backlog_after: i64,
|
||||
/// Sub-line remainder included in `desired`.
|
||||
pub carry: f32,
|
||||
/// Per-flush delta cap in effect.
|
||||
pub cap: i64,
|
||||
|
||||
/// Rolling average inter-event interval (ms); absent until two
|
||||
/// accel-countable events arrived.
|
||||
pub avg_interval_ms: Option<f64>,
|
||||
/// Spacing from the previous flush-bearing record; absent before the
|
||||
/// first. **Global, not per-stream** — the producer's `last_flush_at`
|
||||
/// never resets at stream boundaries, so on a stream's first
|
||||
/// flush-bearing record this measures from the *previous stream*. Use
|
||||
/// [`StreamGroup::intra_stream_flush_spacings_ms`] for per-stream
|
||||
/// cadence.
|
||||
pub ms_since_prev_flush: Option<f64>,
|
||||
/// Finalize only: whole lines discarded with the stream.
|
||||
pub dropped: Option<i64>,
|
||||
|
||||
// Config echo, flattened onto stream_start records only.
|
||||
/// Scroll input mode label in effect (`auto` | `wheel` | `trackpad`).
|
||||
pub mode: Option<String>,
|
||||
/// Events per tick.
|
||||
pub ept: Option<u16>,
|
||||
/// Wheel lines per tick.
|
||||
pub wheel_lpt: Option<u16>,
|
||||
/// Trackpad lines per tick.
|
||||
pub trackpad_lpt: Option<u16>,
|
||||
/// Direction inversion.
|
||||
pub invert: Option<bool>,
|
||||
/// Speed multiplier.
|
||||
pub speed: Option<f32>,
|
||||
/// Viewport height stamped on the config.
|
||||
pub viewport_height: Option<u16>,
|
||||
}
|
||||
|
||||
impl ScrollLogLine {
|
||||
/// Whether this is a stream-start record.
|
||||
pub fn is_stream_start(&self) -> bool {
|
||||
self.evt == EVT_STREAM_START
|
||||
}
|
||||
|
||||
/// Whether this is a mid-stream flush record.
|
||||
pub fn is_flush(&self) -> bool {
|
||||
self.evt == EVT_FLUSH
|
||||
}
|
||||
|
||||
/// Whether this is a finalize record.
|
||||
pub fn is_finalize(&self) -> bool {
|
||||
self.evt == EVT_FINALIZE
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a `KIGI_SCROLL_LOG` JSONL file into records.
|
||||
///
|
||||
/// Every line must parse — errors carry the 1-based line number and the
|
||||
/// offending line. Call after the capture is quiescent (the producer
|
||||
/// force-flushes on finalize, so a file whose last gesture finalized ends
|
||||
/// on a record boundary); mid-write reads can see a torn tail line, which
|
||||
/// fails here by design — synchronize with [`wait_for_finalize_count`]
|
||||
/// first.
|
||||
pub fn parse_jsonl(path: &Path) -> Result<Vec<ScrollLogLine>> {
|
||||
let raw = std::fs::read_to_string(path)
|
||||
.with_context(|| format!("failed to read scroll log {}", path.display()))?;
|
||||
parse_jsonl_str(&raw).with_context(|| format!("scroll log {}", path.display()))
|
||||
}
|
||||
|
||||
/// [`parse_jsonl`] over an in-memory JSONL string (fixtures, pre-read files).
|
||||
pub fn parse_jsonl_str(raw: &str) -> Result<Vec<ScrollLogLine>> {
|
||||
raw.lines()
|
||||
.enumerate()
|
||||
.map(|(idx, line)| {
|
||||
serde_json::from_str(line)
|
||||
.with_context(|| format!("line {}: failed to parse {line:?}", idx + 1))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// One recorded gesture: `stream_start` → `flush`* → `finalize`.
|
||||
///
|
||||
/// `finalize` is `None` only for a trailing stream still in flight when the
|
||||
/// capture ended: finalize rides the 80ms-gap and direction-flip
|
||||
/// transitions, so a capture cut mid-gesture leaves the last stream open.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StreamGroup<'a> {
|
||||
/// The `stream_start` record (carries the config echo).
|
||||
pub start: &'a ScrollLogLine,
|
||||
/// Mid-stream `flush` records, in file order.
|
||||
pub flushes: Vec<&'a ScrollLogLine>,
|
||||
/// The `finalize` record; `None` for a trailing in-flight stream.
|
||||
pub finalize: Option<&'a ScrollLogLine>,
|
||||
}
|
||||
|
||||
impl<'a> StreamGroup<'a> {
|
||||
/// Whether the stream's finalize record was captured.
|
||||
pub fn is_finalized(&self) -> bool {
|
||||
self.finalize.is_some()
|
||||
}
|
||||
|
||||
/// Flush-bearing records (flushes, then finalize if present), in order.
|
||||
pub fn flush_bearing(&self) -> impl Iterator<Item = &'a ScrollLogLine> + '_ {
|
||||
self.flushes.iter().copied().chain(self.finalize)
|
||||
}
|
||||
|
||||
/// Intra-stream flush spacings (ms), in order.
|
||||
///
|
||||
/// Skips the stream's **first** flush-bearing record: the producer's
|
||||
/// `ms_since_prev_flush` is global (its `last_flush_at` never resets at
|
||||
/// stream boundaries — see `scroll_log.rs`), so the first value
|
||||
/// measures from the previous stream's last flush/finalize and says
|
||||
/// nothing about this stream's cadence. Every subsequent flush-bearing
|
||||
/// record's spacing is intra-stream by construction.
|
||||
pub fn intra_stream_flush_spacings_ms(&self) -> Vec<f64> {
|
||||
self.flush_bearing()
|
||||
.skip(1)
|
||||
.filter_map(|record| record.ms_since_prev_flush)
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Group parsed records into per-gesture [`StreamGroup`]s.
|
||||
///
|
||||
/// Expects the shape the pager emits when the recorder exists for the whole
|
||||
/// session (`KIGI_SCROLL_LOG` set at spawn): `stream_start` → `flush`* →
|
||||
/// `finalize`, repeated, with at most one trailing unfinalized stream. A
|
||||
/// direction flip emits `finalize` and the next `stream_start` at the same
|
||||
/// `ts_ms`; that boundary is a plain group boundary here.
|
||||
///
|
||||
/// Malformed shapes (flush/finalize before any start, start while a stream
|
||||
/// is open) are errors: with a spawn-time recorder they indicate producer
|
||||
/// drift. Caveat: a `/debug log` *runtime-toggled* recorder can begin
|
||||
/// mid-stream and legitimately open with an orphan flush/finalize — the
|
||||
/// matrix never does that, so it is rejected rather than silently grouped.
|
||||
pub fn group_streams(records: &[ScrollLogLine]) -> Result<Vec<StreamGroup<'_>>> {
|
||||
let mut groups: Vec<StreamGroup<'_>> = Vec::new();
|
||||
let mut open: Option<StreamGroup<'_>> = None;
|
||||
|
||||
for (idx, record) in records.iter().enumerate() {
|
||||
let record_no = idx + 1;
|
||||
match record.evt.as_str() {
|
||||
EVT_STREAM_START => {
|
||||
if open.is_some() {
|
||||
bail!(
|
||||
"record {record_no}: stream_start at ts_ms={} while the previous \
|
||||
stream is still open (producer emits finalize before the next start)",
|
||||
record.ts_ms
|
||||
);
|
||||
}
|
||||
open = Some(StreamGroup {
|
||||
start: record,
|
||||
flushes: Vec::new(),
|
||||
finalize: None,
|
||||
});
|
||||
}
|
||||
EVT_FLUSH => match open.as_mut() {
|
||||
Some(group) => group.flushes.push(record),
|
||||
None => bail!(
|
||||
"record {record_no}: flush at ts_ms={} with no open stream \
|
||||
(missing stream_start)",
|
||||
record.ts_ms
|
||||
),
|
||||
},
|
||||
EVT_FINALIZE => match open.take() {
|
||||
Some(mut group) => {
|
||||
group.finalize = Some(record);
|
||||
groups.push(group);
|
||||
}
|
||||
None => bail!(
|
||||
"record {record_no}: finalize at ts_ms={} with no open stream \
|
||||
(missing stream_start)",
|
||||
record.ts_ms
|
||||
),
|
||||
},
|
||||
other => bail!("record {record_no}: unknown evt {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
// A trailing stream still in flight at capture end is legitimate.
|
||||
groups.extend(open);
|
||||
Ok(groups)
|
||||
}
|
||||
|
||||
/// Poll interval for [`wait_for_finalize_count`]. Short enough that the
|
||||
/// wait adds at most ~10ms latency past the write, long enough not to spin.
|
||||
const FINALIZE_POLL_INTERVAL: Duration = Duration::from_millis(10);
|
||||
|
||||
/// Block until `path` contains at least `n` finalize records, or `timeout`
|
||||
/// expires.
|
||||
///
|
||||
/// The producer force-flushes its `BufWriter` on every finalize record
|
||||
/// (gesture boundary — the `tail -f` contract in `scroll_log.rs`), so
|
||||
/// polling the file is race-free for finalize counting: once the flush
|
||||
/// lands, the line is fully present. Counting uses a raw substring match
|
||||
/// (`"evt":"finalize"` — serde_json's compact encoding) rather than a full
|
||||
/// parse so a torn non-finalize tail mid-write can't fail the wait. A
|
||||
/// not-yet-created file (the recorder opens lazily on the first record)
|
||||
/// counts as zero.
|
||||
pub fn wait_for_finalize_count(path: &Path, n: usize, timeout: Duration) -> Result<()> {
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
let count = count_finalize_lines(path)?;
|
||||
if count >= n {
|
||||
return Ok(());
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
bail!(
|
||||
"timed out after {timeout:?} waiting for {n} finalize record(s) in {}: found {count}",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
std::thread::sleep(FINALIZE_POLL_INTERVAL);
|
||||
}
|
||||
}
|
||||
|
||||
/// Count finalize records by raw substring; missing file counts as zero.
|
||||
fn count_finalize_lines(path: &Path) -> Result<usize> {
|
||||
match std::fs::read_to_string(path) {
|
||||
Ok(raw) => Ok(raw.matches("\"evt\":\"finalize\"").count()),
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(0),
|
||||
Err(err) => {
|
||||
Err(err).with_context(|| format!("failed to read scroll log {}", path.display()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// Fixture lines shaped exactly like the producer's serde output
|
||||
// (compact JSON, snake_case evt/trigger, config echo flattened onto
|
||||
// stream_start, skip-if-None optionals) — copied from the wire format
|
||||
// pinned by `scroll_log_records_flood_flushes_and_capped_finalize_drop`
|
||||
// in `kigi-tui/src/input/mouse/tests.rs`.
|
||||
const START: &str = r#"{"ts_ms":0.0,"evt":"stream_start","trigger":"event","kind":"unknown","events_total":0,"events_since_flush":0,"accel":1.0,"desired":0.0,"applied_total":0,"flushed":0,"backlog_after":0,"carry":0.0,"cap":6,"mode":"trackpad","ept":3,"wheel_lpt":3,"trackpad_lpt":3,"invert":false,"speed":1.0,"viewport_height":40}"#;
|
||||
const FLUSH_FIRST: &str = r#"{"ts_ms":16.0,"evt":"flush","trigger":"event","kind":"trackpad","events_total":9,"events_since_flush":9,"avg_interval_ms":2.0,"accel":1.0,"desired":9.4,"applied_total":6,"flushed":6,"backlog_after":3,"carry":0.4,"cap":6}"#;
|
||||
const FLUSH_SECOND: &str = r#"{"ts_ms":32.0,"evt":"flush","trigger":"tick","kind":"trackpad","events_total":17,"events_since_flush":8,"avg_interval_ms":2.0,"accel":1.0,"desired":17.4,"applied_total":12,"flushed":6,"backlog_after":5,"carry":0.4,"cap":6,"ms_since_prev_flush":16.0}"#;
|
||||
const FINALIZE: &str = r#"{"ts_ms":114.0,"evt":"finalize","trigger":"finalize","kind":"trackpad","events_total":50,"events_since_flush":33,"avg_interval_ms":2.0,"accel":1.0,"desired":50.0,"applied_total":18,"flushed":6,"backlog_after":26,"carry":0.0,"cap":6,"ms_since_prev_flush":82.0,"dropped":26}"#;
|
||||
|
||||
fn jsonl(lines: &[&str]) -> String {
|
||||
let mut out = lines.join("\n");
|
||||
out.push('\n'); // producer's writeln! always terminates lines
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_producer_shaped_fixture_lines() {
|
||||
let records =
|
||||
parse_jsonl_str(&jsonl(&[START, FLUSH_FIRST, FLUSH_SECOND, FINALIZE])).expect("parse");
|
||||
assert_eq!(records.len(), 4);
|
||||
|
||||
let start = &records[0];
|
||||
assert!(start.is_stream_start());
|
||||
assert_eq!(start.trigger, "event");
|
||||
assert_eq!(start.kind, "unknown");
|
||||
assert_eq!(start.events_total, 0);
|
||||
assert_eq!(start.cap, 6);
|
||||
// Config echo rides stream_start only.
|
||||
assert_eq!(start.mode.as_deref(), Some("trackpad"));
|
||||
assert_eq!(start.ept, Some(3));
|
||||
assert_eq!(start.wheel_lpt, Some(3));
|
||||
assert_eq!(start.trackpad_lpt, Some(3));
|
||||
assert_eq!(start.invert, Some(false));
|
||||
assert_eq!(start.speed, Some(1.0));
|
||||
assert_eq!(start.viewport_height, Some(40));
|
||||
assert!(start.dropped.is_none());
|
||||
assert!(start.ms_since_prev_flush.is_none());
|
||||
|
||||
let first_flush = &records[1];
|
||||
assert!(first_flush.is_flush());
|
||||
assert_eq!(first_flush.flushed, 6);
|
||||
assert_eq!(first_flush.avg_interval_ms, Some(2.0));
|
||||
// No flush-bearing record precedes it in this capture.
|
||||
assert!(first_flush.ms_since_prev_flush.is_none());
|
||||
assert!(first_flush.mode.is_none(), "flushes skip the config echo");
|
||||
|
||||
let finalize = &records[3];
|
||||
assert!(finalize.is_finalize());
|
||||
assert_eq!(finalize.trigger, "finalize");
|
||||
assert_eq!(finalize.dropped, Some(26));
|
||||
assert_eq!(finalize.backlog_after, 26);
|
||||
assert_eq!(finalize.ms_since_prev_flush, Some(82.0));
|
||||
assert_eq!(finalize.ts_ms, 114.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_fields_are_tolerated_for_additive_producer_changes() {
|
||||
let with_future_field = FLUSH_SECOND.replace(
|
||||
"\"ms_since_prev_flush\":16.0",
|
||||
"\"ms_since_prev_flush\":16.0,\"future_field\":true",
|
||||
);
|
||||
let records = parse_jsonl_str(&jsonl(&[START, &with_future_field]))
|
||||
.expect("additive fields must not break ingestion");
|
||||
assert_eq!(records.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_required_field_fails_with_line_number() {
|
||||
// Simulate a producer-side rename: `carry` disappears from line 2.
|
||||
let renamed = FLUSH_FIRST.replace("\"carry\":0.4,", "");
|
||||
let err = parse_jsonl_str(&jsonl(&[START, &renamed])).expect_err("drift must fail");
|
||||
let chain = format!("{err:#}");
|
||||
assert!(chain.contains("line 2"), "no line number in: {chain}");
|
||||
assert!(chain.contains("carry"), "no missing-field name in: {chain}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn groups_flip_boundary_and_trailing_unfinalized_stream() {
|
||||
// Direction flip: finalize and the next stream_start share ts_ms
|
||||
// (both emitted from the same on_scroll_event call). The capture
|
||||
// ends with stream 2 still in flight (no finalize).
|
||||
let flip_finalize = FINALIZE.replace("\"ts_ms\":114.0", "\"ts_ms\":40.0");
|
||||
let flip_start = START.replace("\"ts_ms\":0.0", "\"ts_ms\":40.0");
|
||||
let records = parse_jsonl_str(&jsonl(&[
|
||||
START,
|
||||
FLUSH_FIRST,
|
||||
FLUSH_SECOND,
|
||||
&flip_finalize,
|
||||
&flip_start,
|
||||
&FLUSH_SECOND.replace("\"ts_ms\":32.0", "\"ts_ms\":56.0"),
|
||||
]))
|
||||
.expect("parse");
|
||||
|
||||
let groups = group_streams(&records).expect("well-formed grouping");
|
||||
assert_eq!(groups.len(), 2);
|
||||
|
||||
let first = &groups[0];
|
||||
assert!(first.is_finalized());
|
||||
assert_eq!(first.flushes.len(), 2);
|
||||
assert_eq!(first.finalize.expect("finalized").ts_ms, 40.0);
|
||||
|
||||
let trailing = &groups[1];
|
||||
assert!(!trailing.is_finalized(), "in-flight at capture end");
|
||||
assert_eq!(trailing.start.ts_ms, 40.0, "flip start shares finalize ts");
|
||||
assert_eq!(trailing.flushes.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orphan_records_and_double_start_are_rejected() {
|
||||
let records = parse_jsonl_str(&jsonl(&[FLUSH_FIRST])).expect("parse");
|
||||
let err = group_streams(&records).expect_err("orphan flush");
|
||||
assert!(format!("{err:#}").contains("no open stream"));
|
||||
|
||||
let records = parse_jsonl_str(&jsonl(&[FINALIZE])).expect("parse");
|
||||
let err = group_streams(&records).expect_err("orphan finalize");
|
||||
assert!(format!("{err:#}").contains("no open stream"));
|
||||
|
||||
let records = parse_jsonl_str(&jsonl(&[START, FLUSH_FIRST, START])).expect("parse");
|
||||
let err = group_streams(&records).expect_err("start while open");
|
||||
assert!(format!("{err:#}").contains("still open"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn per_stream_spacing_skips_the_global_first_flush_record() {
|
||||
// Stream 2's first flush carries ms_since_prev_flush measured from
|
||||
// stream 1's finalize (producer's last_flush_at is global) — 500ms
|
||||
// of inter-gesture idle that is NOT stream-2 cadence and must be
|
||||
// skipped; the later flush (16ms) and finalize (82ms) are kept.
|
||||
let s2_start = START.replace("\"ts_ms\":0.0", "\"ts_ms\":600.0");
|
||||
let s2_flush_global = FLUSH_SECOND
|
||||
.replace("\"ts_ms\":32.0", "\"ts_ms\":616.0")
|
||||
.replace(
|
||||
"\"ms_since_prev_flush\":16.0",
|
||||
"\"ms_since_prev_flush\":500.0",
|
||||
);
|
||||
let s2_flush_intra = FLUSH_SECOND.replace("\"ts_ms\":32.0", "\"ts_ms\":632.0");
|
||||
let s2_finalize = FINALIZE.replace("\"ts_ms\":114.0", "\"ts_ms\":714.0");
|
||||
let records = parse_jsonl_str(&jsonl(&[
|
||||
START,
|
||||
FLUSH_FIRST,
|
||||
FINALIZE,
|
||||
&s2_start,
|
||||
&s2_flush_global,
|
||||
&s2_flush_intra,
|
||||
&s2_finalize,
|
||||
]))
|
||||
.expect("parse");
|
||||
|
||||
let groups = group_streams(&records).expect("grouping");
|
||||
assert_eq!(groups.len(), 2);
|
||||
assert_eq!(
|
||||
groups[1].intra_stream_flush_spacings_ms(),
|
||||
vec![16.0, 82.0],
|
||||
"the 500ms cross-stream value must be skipped"
|
||||
);
|
||||
// Stream 1: first flush has no spacing at all (recorder start);
|
||||
// only the finalize's intra-stream spacing remains.
|
||||
assert_eq!(groups[0].intra_stream_flush_spacings_ms(), vec![82.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wait_for_finalize_count_write_then_check_phases() {
|
||||
use std::io::Write;
|
||||
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = dir.path().join("scroll-log.jsonl");
|
||||
|
||||
// Lazy-open recorder: the file does not exist yet — n=0 succeeds.
|
||||
wait_for_finalize_count(&path, 0, Duration::ZERO).expect("n=0 on missing file");
|
||||
// …but n=1 times out, reporting the current count.
|
||||
let err = wait_for_finalize_count(&path, 1, Duration::from_millis(30))
|
||||
.expect_err("missing file cannot satisfy n=1");
|
||||
assert!(format!("{err:#}").contains("found 0"), "err: {err:#}");
|
||||
|
||||
let mut file = std::fs::File::create(&path).expect("create");
|
||||
writeln!(file, "{START}").expect("write");
|
||||
writeln!(file, "{FLUSH_FIRST}").expect("write");
|
||||
writeln!(file, "{FINALIZE}").expect("write");
|
||||
file.flush().expect("flush");
|
||||
wait_for_finalize_count(&path, 1, Duration::from_secs(5)).expect("one finalize present");
|
||||
|
||||
// A torn tail mid-write (BufWriter fill boundary) must not fail the
|
||||
// wait — counting is a raw substring match, not a parse.
|
||||
write!(file, "{{\"ts_ms\":9").expect("torn tail");
|
||||
file.flush().expect("flush");
|
||||
wait_for_finalize_count(&path, 1, Duration::from_secs(5)).expect("torn tail tolerated");
|
||||
let err = wait_for_finalize_count(&path, 2, Duration::from_millis(30))
|
||||
.expect_err("only one finalize so far");
|
||||
assert!(format!("{err:#}").contains("found 1"), "err: {err:#}");
|
||||
|
||||
// Completing the torn record into a second finalize satisfies n=2.
|
||||
writeln!(file, ".0,\"evt\":\"finalize\"}}").expect("complete tail");
|
||||
file.flush().expect("flush");
|
||||
wait_for_finalize_count(&path, 2, Duration::from_secs(5)).expect("two finalizes");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wait_for_finalize_count_observes_concurrent_appends() {
|
||||
use std::io::Write;
|
||||
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = dir.path().join("scroll-log.jsonl");
|
||||
let writer_path = path.clone();
|
||||
|
||||
// Bounded writer delay, generous deadline: the wait must return as
|
||||
// soon as the poll sees the flushed finalize, not at the deadline.
|
||||
let writer = std::thread::spawn(move || {
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
let mut file = std::fs::File::create(&writer_path).expect("create");
|
||||
writeln!(file, "{START}").expect("write");
|
||||
writeln!(file, "{FINALIZE}").expect("write");
|
||||
file.flush().expect("flush");
|
||||
});
|
||||
|
||||
wait_for_finalize_count(&path, 1, Duration::from_secs(10))
|
||||
.expect("poll must observe the concurrent append");
|
||||
writer.join().expect("writer thread");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
//! Scroll validation matrix.
|
||||
//!
|
||||
//! The scroll matrix drives the pager binary in a PTY with
|
||||
//! `KIGI_SCROLL_LOG` pointed at a tempfile, then validates the pager's
|
||||
//! flight-recorder JSONL (producer:
|
||||
//! `kigi-tui/src/input/scroll_log.rs`) against gesture invariants.
|
||||
//!
|
||||
//! Layers (per-cell flow, executed by [`runner::run_cell`]):
|
||||
//! [`session`] spawns the primed pager → the cell's [`gestures`] table is
|
||||
//! replayed as timed SGR reports → [`log`] parses and groups the recorder
|
||||
//! JSONL (finalize-synchronized, no fixed sleeps) → the cell's
|
||||
//! [`invariants`] judge the [`cells`] row's verdict, xfail-aware, and
|
||||
//! [`report`] renders the verdicts (table, `report.json`, exit code).
|
||||
//! Entry points: the curated CI tier (`tests/scroll_matrix_curated.rs`) and
|
||||
//! the local full sweep (`src/bin/scroll_matrix.rs`).
|
||||
//!
|
||||
//! ## No pager dependency
|
||||
//!
|
||||
//! The harness reaches the pager **only** through the spawned binary
|
||||
//! (`PAGER_BINARY` / `env::pager_binary`); this module re-declares the wire
|
||||
//! schema instead of importing pager types. That duplication is deliberate:
|
||||
//! [`log::ScrollLogLine`] keeps every always-emitted producer field
|
||||
//! **required**, so a rename/removal on the pager side fails deserialization
|
||||
//! loudly here (schema-drift tripwire), while unknown fields are tolerated
|
||||
//! so additive producer changes don't break older matrix code. The
|
||||
//! producer-side twin lives in `kigi-tui/src/input/mouse/tests.rs`
|
||||
//! (wire-format fixture test asserting the same key set on raw JSON).
|
||||
|
||||
pub mod cells;
|
||||
pub mod gestures;
|
||||
pub mod invariants;
|
||||
pub mod log;
|
||||
pub mod report;
|
||||
pub mod runner;
|
||||
pub mod session;
|
||||
|
||||
pub use cells::{CELLS, ExpectedProfile, MatrixCell, Tier, curated};
|
||||
pub use gestures::{GestureId, WheelStep, direction_counts};
|
||||
pub use invariants::{InvariantId, InvariantResult, check_log_invariant};
|
||||
pub use log::{
|
||||
EVT_FINALIZE, EVT_FLUSH, EVT_STREAM_START, ScrollLogLine, StreamGroup, group_streams,
|
||||
parse_jsonl, parse_jsonl_str, wait_for_finalize_count,
|
||||
};
|
||||
pub use report::{
|
||||
CellReport, CellStatus, InvariantReport, InvariantStatus, exit_code, summary_table,
|
||||
write_report_json,
|
||||
};
|
||||
pub use runner::run_cell;
|
||||
pub use session::{
|
||||
SessionKind, marker_line, marker_response, marker_screen_row, spawn_marker_session,
|
||||
spawn_settled_marker_session, spawn_streaming_marker_session, topmost_visible_marker,
|
||||
};
|
||||
@@ -0,0 +1,283 @@
|
||||
//! Cell verdicts: the `report.json` artifact, the stdout summary table, and
|
||||
//! the exit-code policy shared by the curated CI tests and the
|
||||
//! `scroll-matrix` sweep binary.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::Serialize;
|
||||
|
||||
/// Verdict of one invariant row within a cell run.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum InvariantStatus {
|
||||
/// Held, and was expected to hold.
|
||||
Pass,
|
||||
/// Violated outside the cell's xfail set.
|
||||
Fail,
|
||||
/// Violated inside the xfail set (the declared known bug).
|
||||
XFail,
|
||||
/// Held despite being in the xfail set — the bug got fixed or the cell
|
||||
/// rotted; either way the cell must be promoted out of xfail, so this
|
||||
/// fails the run exactly like [`InvariantStatus::Fail`].
|
||||
XPass,
|
||||
}
|
||||
|
||||
/// Cell-level verdict: the worst of its invariant rows
|
||||
/// (`Fail > XPass > XFail > Pass` — see `runner::classify`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum CellStatus {
|
||||
Pass,
|
||||
Fail,
|
||||
XFail,
|
||||
XPass,
|
||||
}
|
||||
|
||||
impl CellStatus {
|
||||
/// Fixed-width table label.
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
CellStatus::Pass => "PASS",
|
||||
CellStatus::Fail => "FAIL",
|
||||
CellStatus::XFail => "XFAIL",
|
||||
CellStatus::XPass => "XPASS",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One invariant row of a [`CellReport`].
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct InvariantReport {
|
||||
/// Design vocabulary id (`I-ORD`, …).
|
||||
pub id: String,
|
||||
pub status: InvariantStatus,
|
||||
/// Violation detail, or the promote-out-of-xfail note on XPass.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub detail: Option<String>,
|
||||
}
|
||||
|
||||
/// Verdict of one matrix cell run.
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct CellReport {
|
||||
pub cell_id: String,
|
||||
/// `curated` | `full`.
|
||||
pub tier: String,
|
||||
pub status: CellStatus,
|
||||
/// One row per declared invariant; empty when the run aborted before
|
||||
/// evaluation (see `note`).
|
||||
pub invariants: Vec<InvariantReport>,
|
||||
/// The cell's `KIGI_SCROLL_LOG` capture (kept for post-mortems).
|
||||
pub log_path: String,
|
||||
/// Streams grouped out of the capture (finalized + trailing in-flight).
|
||||
pub streams: usize,
|
||||
pub duration_ms: u64,
|
||||
/// Phase note for runs that never reached invariant evaluation (setup
|
||||
/// panic, finalize-wait timeout, the per-cell hard cap).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub note: Option<String>,
|
||||
}
|
||||
|
||||
/// Exit-code policy: nonzero iff any cell **Fail** or **XPass** — expected
|
||||
/// failures (XFail) are green, but a fixed-or-rotted xfail cell must break
|
||||
/// the run so it gets promoted instead of silently absorbed.
|
||||
pub fn exit_code(reports: &[CellReport]) -> u8 {
|
||||
let failed = reports
|
||||
.iter()
|
||||
.any(|r| matches!(r.status, CellStatus::Fail | CellStatus::XPass));
|
||||
u8::from(failed)
|
||||
}
|
||||
|
||||
/// Write `report.json` (pretty, array of [`CellReport`]) into `dir`,
|
||||
/// creating it as needed; returns the file path.
|
||||
pub fn write_report_json(reports: &[CellReport], dir: &Path) -> Result<PathBuf> {
|
||||
std::fs::create_dir_all(dir)
|
||||
.with_context(|| format!("create artifacts dir {}", dir.display()))?;
|
||||
let path = dir.join("report.json");
|
||||
let json = serde_json::to_string_pretty(reports).context("serialize cell reports")?;
|
||||
std::fs::write(&path, json).with_context(|| format!("write {}", path.display()))?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Detail column source: the run's phase note, else the first non-Pass
|
||||
/// invariant row's `id: detail`.
|
||||
fn detail_for(report: &CellReport) -> String {
|
||||
if let Some(note) = &report.note {
|
||||
return note.clone();
|
||||
}
|
||||
report
|
||||
.invariants
|
||||
.iter()
|
||||
.find(|row| row.status != InvariantStatus::Pass)
|
||||
.map(|row| {
|
||||
let detail = row.detail.as_deref().unwrap_or("");
|
||||
format!("{}: {detail}", row.id)
|
||||
})
|
||||
.unwrap_or_else(|| "-".to_owned())
|
||||
}
|
||||
|
||||
/// Truncation width for the table's detail column (full text lives in
|
||||
/// `report.json`).
|
||||
const DETAIL_WIDTH: usize = 72;
|
||||
|
||||
/// Render the aligned one-row-per-cell summary table. Plain ASCII, no
|
||||
/// ANSI/TTY dependence — safe for CI logs and `| tee`.
|
||||
pub fn summary_table(reports: &[CellReport]) -> String {
|
||||
let mut rows: Vec<[String; 6]> = vec![[
|
||||
"CELL".into(),
|
||||
"TIER".into(),
|
||||
"STATUS".into(),
|
||||
"STREAMS".into(),
|
||||
"TIME".into(),
|
||||
"DETAIL".into(),
|
||||
]];
|
||||
for report in reports {
|
||||
let mut detail = detail_for(report).replace(['\n', '\r'], " ");
|
||||
if detail.len() > DETAIL_WIDTH {
|
||||
let cut = (0..=DETAIL_WIDTH.saturating_sub(3))
|
||||
.rev()
|
||||
.find(|&i| detail.is_char_boundary(i))
|
||||
.unwrap_or(0);
|
||||
detail.truncate(cut);
|
||||
detail.push_str("...");
|
||||
}
|
||||
rows.push([
|
||||
report.cell_id.clone(),
|
||||
report.tier.clone(),
|
||||
report.status.as_str().to_owned(),
|
||||
report.streams.to_string(),
|
||||
format!("{}ms", report.duration_ms),
|
||||
detail,
|
||||
]);
|
||||
}
|
||||
|
||||
let mut widths = [0usize; 6];
|
||||
for row in &rows {
|
||||
for (width, cell) in widths.iter_mut().zip(row) {
|
||||
*width = (*width).max(cell.len());
|
||||
}
|
||||
}
|
||||
let mut out = String::new();
|
||||
for row in &rows {
|
||||
let mut line = String::new();
|
||||
for (width, cell) in widths.iter().zip(row) {
|
||||
line.push_str(&format!("{cell:<width$} "));
|
||||
}
|
||||
out.push_str(line.trim_end());
|
||||
out.push('\n');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn report(cell_id: &str, status: CellStatus, rows: Vec<InvariantReport>) -> CellReport {
|
||||
CellReport {
|
||||
cell_id: cell_id.to_owned(),
|
||||
tier: "curated".to_owned(),
|
||||
status,
|
||||
invariants: rows,
|
||||
log_path: format!("/tmp/{cell_id}.jsonl"),
|
||||
streams: 1,
|
||||
duration_ms: 1234,
|
||||
note: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn row(id: &str, status: InvariantStatus, detail: Option<&str>) -> InvariantReport {
|
||||
InvariantReport {
|
||||
id: id.to_owned(),
|
||||
status,
|
||||
detail: detail.map(str::to_owned),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exit_code_is_nonzero_iff_fail_or_xpass() {
|
||||
let pass = report("a", CellStatus::Pass, vec![]);
|
||||
let xfail = report("b", CellStatus::XFail, vec![]);
|
||||
let fail = report("c", CellStatus::Fail, vec![]);
|
||||
let xpass = report("d", CellStatus::XPass, vec![]);
|
||||
|
||||
assert_eq!(exit_code(&[]), 0);
|
||||
assert_eq!(
|
||||
exit_code(&[pass.clone(), xfail.clone()]),
|
||||
0,
|
||||
"XFail is green"
|
||||
);
|
||||
assert_eq!(exit_code(&[pass.clone(), fail]), 1);
|
||||
// The fixed-bug tripwire: an xfail cell that PASSES must break the run.
|
||||
assert_eq!(exit_code(&[pass, xfail, xpass]), 1, "XPass must be nonzero");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_json_shape_and_optional_fields() {
|
||||
let full = report(
|
||||
"cell_x",
|
||||
CellStatus::XFail,
|
||||
vec![row("I-NO-DROP", InvariantStatus::XFail, Some("dropped 74"))],
|
||||
);
|
||||
let value = serde_json::to_value(&full).expect("serialize");
|
||||
assert_eq!(value["cell_id"], "cell_x");
|
||||
assert_eq!(value["tier"], "curated");
|
||||
assert_eq!(value["status"], "x_fail", "snake_case enum labels");
|
||||
assert_eq!(value["streams"], 1);
|
||||
assert_eq!(value["duration_ms"], 1234);
|
||||
assert_eq!(value["invariants"][0]["id"], "I-NO-DROP");
|
||||
assert_eq!(value["invariants"][0]["status"], "x_fail");
|
||||
assert_eq!(value["invariants"][0]["detail"], "dropped 74");
|
||||
assert!(
|
||||
value.get("note").is_none(),
|
||||
"None note must not serialize: {value}"
|
||||
);
|
||||
|
||||
let pass_row = serde_json::to_value(row("I-ORD", InvariantStatus::Pass, None)).unwrap();
|
||||
assert!(pass_row.get("detail").is_none(), "None detail skipped");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_report_json_creates_dir_and_file() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let nested = dir.path().join("artifacts");
|
||||
let path = write_report_json(&[report("a", CellStatus::Pass, vec![])], &nested)
|
||||
.expect("write report");
|
||||
assert_eq!(path, nested.join("report.json"));
|
||||
let raw = std::fs::read_to_string(&path).expect("read back");
|
||||
let parsed: serde_json::Value = serde_json::from_str(&raw).expect("valid json");
|
||||
assert_eq!(parsed[0]["cell_id"], "a");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summary_table_aligns_columns_and_stays_single_line_per_cell() {
|
||||
let reports = vec![
|
||||
report("short", CellStatus::Pass, vec![]),
|
||||
report(
|
||||
"a_much_longer_cell_identifier",
|
||||
CellStatus::Fail,
|
||||
vec![row(
|
||||
"I-CAP",
|
||||
InvariantStatus::Fail,
|
||||
Some(&"flushed 40 exceeds cap 25\nwith a newline".repeat(4)),
|
||||
)],
|
||||
),
|
||||
];
|
||||
let table = summary_table(&reports);
|
||||
let lines: Vec<&str> = table.lines().collect();
|
||||
assert_eq!(lines.len(), 3, "header + one row per cell:\n{table}");
|
||||
|
||||
// Every row's STATUS column starts where the header's does.
|
||||
let status_col = lines[0].find("STATUS").expect("header STATUS");
|
||||
assert_eq!(&lines[1][status_col..status_col + 4], "PASS");
|
||||
assert_eq!(&lines[2][status_col..status_col + 4], "FAIL");
|
||||
|
||||
// Failure detail is carried (truncated, newlines flattened).
|
||||
assert!(lines[2].contains("I-CAP: flushed 40"), "table:\n{table}");
|
||||
assert!(lines[2].contains("..."), "long detail truncated:\n{table}");
|
||||
assert!(
|
||||
!table.contains('\x1b'),
|
||||
"table must be plain ASCII (non-TTY-safe)"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,465 @@
|
||||
//! The per-cell executor: spawn the primed pager, replay the gesture,
|
||||
//! synchronize on the recorder, judge the invariants, classify the verdict.
|
||||
//!
|
||||
//! ## Blocking model
|
||||
//!
|
||||
//! A cell's body ([`run_cell_inner`]) is host-paced end to end — PTY drains,
|
||||
//! `thread::sleep` gesture gaps, finalize polling — so [`run_cell`] runs it
|
||||
//! on `spawn_blocking` (driving the async session spawns via
|
||||
//! `Handle::block_on`, which requires a **multi-thread** runtime) and applies
|
||||
//! the per-cell hard cap with `tokio::time::timeout` on the join handle.
|
||||
//! That also converts setup panics (the session preambles assert with the
|
||||
//! screen contents) into a `Fail` report instead of killing the whole sweep.
|
||||
//! A capped cell's blocking task cannot be aborted mid-syscall: it is left
|
||||
//! to unwind on its own bounded waits (its `Drop`s kill the pager child and
|
||||
//! mock server), while the sweep moves on.
|
||||
//!
|
||||
//! ## Harness-side invariants
|
||||
//!
|
||||
//! [`InvariantId::Screen`] and [`InvariantId::Quiet`] are checked here (the
|
||||
//! log alone can't see the viewport or repaints — `invariants.rs` panics on
|
||||
//! them by contract): I-QUIET counts frames in a post-finalize watermark
|
||||
//! window, I-SCREEN replays the per-stream `applied_total`s through a
|
||||
//! bottom-clamped travel simulation and compares the topmost visible marker
|
||||
//! against the session baseline.
|
||||
|
||||
use std::path::Path;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
|
||||
use super::cells::{MatrixCell, Tier};
|
||||
use super::invariants::{InvariantId, InvariantResult, check_log_invariant};
|
||||
use super::log::{StreamGroup, group_streams, parse_jsonl, wait_for_finalize_count};
|
||||
use super::report::{CellReport, CellStatus, InvariantReport, InvariantStatus};
|
||||
use super::session::{
|
||||
SessionKind, WHEEL_COL, WHEEL_ROW, spawn_marker_session, topmost_visible_marker,
|
||||
};
|
||||
|
||||
/// Transcript height for every cell: comfortably taller than the 50-row PTY
|
||||
/// (the preamble's scrollable-baseline guard) with enough headroom that the
|
||||
/// small gestures judged by I-SCREEN never clamp at the transcript top.
|
||||
/// Deliberate travel clamping (floods can deliver hundreds of rows) is fine:
|
||||
/// no log-side invariant reads the viewport.
|
||||
const MARKER_COUNT: usize = 400;
|
||||
|
||||
/// Recorder-synchronization budget: every gesture table spans < 1.5s and a
|
||||
/// stream finalizes 80ms after its last event, so 5s only trips on a real
|
||||
/// wedge (or a stall so long the cell is unusable anyway).
|
||||
const FINALIZE_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
/// Post-finalize settle: consume the gesture-era PTY backlog so the quiet
|
||||
/// window below counts only NEW frames (the harness parses chunks lazily in
|
||||
/// `update`, not at arrival).
|
||||
const PIPELINE_DRAIN: Duration = Duration::from_millis(300);
|
||||
|
||||
/// I-QUIET observation window after the drain + watermark reset.
|
||||
const QUIET_WINDOW: Duration = Duration::from_millis(500);
|
||||
|
||||
/// I-QUIET allowance: a straggler cadence/finalize paint mid-pipeline may
|
||||
/// land after the watermark; repaint CHURN (the A2 symptom) paints dozens.
|
||||
const QUIET_MAX_FRAMES: u64 = 2;
|
||||
|
||||
/// Streaming-session teardown budget: the paced tail is ~7s at spawn time,
|
||||
/// so the released turn completes well inside this.
|
||||
const COMPLETION_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
/// Per-cell hard cap (spawn → verdict). The slowest legitimate cell — the
|
||||
/// streaming preamble plus its post-gesture tail drain — finishes in ~20s.
|
||||
const CELL_HARD_CAP: Duration = Duration::from_secs(60);
|
||||
|
||||
/// `tier` label for [`CellReport`].
|
||||
fn tier_label(tier: Tier) -> &'static str {
|
||||
match tier {
|
||||
Tier::Curated => "curated",
|
||||
Tier::Full => "full",
|
||||
}
|
||||
}
|
||||
|
||||
/// One SGR (DECSET 1006) wheel press report at the shared in-scrollback
|
||||
/// position — 0-based [`WHEEL_ROW`]/[`WHEEL_COL`] encode 1-based on the wire
|
||||
/// (same bytes as the pager e2e `sgr_mouse` helper).
|
||||
fn sgr_wheel_report(button: u16) -> String {
|
||||
format!("\x1b[<{button};{};{}M", WHEEL_COL + 1, WHEEL_ROW + 1)
|
||||
}
|
||||
|
||||
/// Run one matrix cell against `binary`, capturing the recorder JSONL to
|
||||
/// `artifacts_dir/<cell_id>.jsonl` (kept for post-mortems). Never panics and
|
||||
/// never hangs past [`CELL_HARD_CAP`]; every abnormality becomes a `Fail`
|
||||
/// report with a phase note. Requires a multi-thread tokio runtime (see the
|
||||
/// module docs).
|
||||
pub async fn run_cell(cell: &MatrixCell, binary: &Path, artifacts_dir: &Path) -> CellReport {
|
||||
let started = Instant::now();
|
||||
let cell = *cell;
|
||||
let log_path = artifacts_dir.join(format!("{}.jsonl", cell.id));
|
||||
|
||||
let outcome = match std::fs::create_dir_all(artifacts_dir)
|
||||
.with_context(|| format!("create artifacts dir {}", artifacts_dir.display()))
|
||||
{
|
||||
Err(err) => Err(format!("artifacts setup: {err:#}")),
|
||||
Ok(()) => {
|
||||
let handle = tokio::runtime::Handle::current();
|
||||
let (binary, inner_log) = (binary.to_path_buf(), log_path.clone());
|
||||
let task = tokio::task::spawn_blocking(move || {
|
||||
handle.block_on(run_cell_inner(cell, &binary, &inner_log))
|
||||
});
|
||||
match tokio::time::timeout(CELL_HARD_CAP, task).await {
|
||||
Err(_) => Err(format!(
|
||||
"hard cap: cell still running after {CELL_HARD_CAP:?} (phase unknown; \
|
||||
the cell task is left to unwind on its own bounded waits)"
|
||||
)),
|
||||
Ok(Err(join_err)) => Err(format!("panic: {}", panic_message(join_err))),
|
||||
Ok(Ok(Err(err))) => Err(format!("{err:#}")),
|
||||
Ok(Ok(Ok(run))) => Ok(run),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let (status, invariants, streams, note) = match outcome {
|
||||
Ok(run) => {
|
||||
let (status, invariants) = classify(&run.outcomes, cell.xfail);
|
||||
(status, invariants, run.streams, None)
|
||||
}
|
||||
Err(note) => (CellStatus::Fail, Vec::new(), 0, Some(note)),
|
||||
};
|
||||
CellReport {
|
||||
cell_id: cell.id.to_owned(),
|
||||
tier: tier_label(cell.tier).to_owned(),
|
||||
status,
|
||||
invariants,
|
||||
log_path: log_path.display().to_string(),
|
||||
streams,
|
||||
duration_ms: started.elapsed().as_millis() as u64,
|
||||
note,
|
||||
}
|
||||
}
|
||||
|
||||
fn panic_message(err: tokio::task::JoinError) -> String {
|
||||
match err.try_into_panic() {
|
||||
Ok(payload) => payload
|
||||
.downcast_ref::<&str>()
|
||||
.map(|s| (*s).to_owned())
|
||||
.or_else(|| payload.downcast_ref::<String>().cloned())
|
||||
.unwrap_or_else(|| "non-string panic payload".to_owned()),
|
||||
Err(err) => format!("cell task failed without panicking: {err}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything [`run_cell`] needs from a completed (non-aborted) cell body.
|
||||
struct CellRun {
|
||||
outcomes: Vec<(InvariantId, InvariantResult)>,
|
||||
streams: usize,
|
||||
}
|
||||
|
||||
async fn run_cell_inner(cell: MatrixCell, binary: &Path, log_path: &Path) -> Result<CellRun> {
|
||||
// Stale-capture guard: the recorder opens its file lazily on the first
|
||||
// record, so a leftover capture from a previous run would satisfy the
|
||||
// finalize wait with the OLD gesture's records.
|
||||
if log_path.exists() {
|
||||
std::fs::remove_file(log_path)
|
||||
.with_context(|| format!("setup: remove stale capture {}", log_path.display()))?;
|
||||
}
|
||||
let log_value = log_path
|
||||
.to_str()
|
||||
.context("setup: artifacts path is not UTF-8")?;
|
||||
let mut env: Vec<(&str, &str)> = cell.env.to_vec();
|
||||
env.push(("KIGI_SCROLL_LOG", log_value));
|
||||
|
||||
// Live bindings on purpose: `content` owns the mock server (and the
|
||||
// streaming completion gate) — see the session module's footgun docs.
|
||||
let (mut harness, content, baseline) =
|
||||
spawn_marker_session(binary, cell.session, MARKER_COUNT, &env).await;
|
||||
|
||||
// Replay the gesture table: sleep each step's pre-delay (host-side lower
|
||||
// bound — jitter only stretches gaps), then emit its report. Port of the
|
||||
// pager e2e `send_wheel_sequence` loop onto `WheelStep`.
|
||||
for step in cell.gesture.steps(cell.expected.ept) {
|
||||
if step.pre_delay_ms > 0 {
|
||||
std::thread::sleep(Duration::from_millis(step.pre_delay_ms));
|
||||
}
|
||||
harness
|
||||
.inject_keys(sgr_wheel_report(step.button).as_bytes())
|
||||
.context("gesture: inject wheel report")?;
|
||||
}
|
||||
|
||||
wait_for_finalize_count(log_path, cell.gesture.expected_streams(), FINALIZE_TIMEOUT)
|
||||
.context("gesture: finalize wait")?;
|
||||
|
||||
// Drain the gesture-era backlog, then watermark → the quiet window
|
||||
// counts only post-finalize frames; the marker read afterwards sees the
|
||||
// fully painted final viewport (I-SCREEN's input).
|
||||
harness.update(PIPELINE_DRAIN);
|
||||
harness.reset_timing();
|
||||
harness.update(QUIET_WINDOW);
|
||||
let quiet_frames = harness.frame_count();
|
||||
let marker_after = topmost_visible_marker(&harness);
|
||||
|
||||
// Streaming teardown: the CALLER owns the gate release (session-module
|
||||
// contract) — release after the gesture so the pager exits a completed
|
||||
// turn, and prove the release took (the held gate is the alternative
|
||||
// explanation for almost any streaming-cell wedge).
|
||||
if cell.session == SessionKind::Streaming {
|
||||
content.release_agent_completions();
|
||||
let deadline = Instant::now() + COMPLETION_TIMEOUT;
|
||||
while harness.contains_text("Responding") {
|
||||
if Instant::now() >= deadline {
|
||||
bail!("teardown: turn never completed after the gate release");
|
||||
}
|
||||
harness.update(Duration::from_millis(200));
|
||||
}
|
||||
}
|
||||
harness.quit().context("teardown: quit pager")?;
|
||||
drop(content);
|
||||
|
||||
// The pager exited (recorder flushed + closed), so the capture is
|
||||
// complete and torn-tail-free by construction.
|
||||
let records = parse_jsonl(log_path).context("verdict: parse capture")?;
|
||||
let groups = group_streams(&records).context("verdict: group streams")?;
|
||||
|
||||
let outcomes = cell
|
||||
.invariants
|
||||
.iter()
|
||||
.map(|&id| {
|
||||
let result = if id.is_log_side() {
|
||||
check_log_invariant(id, &cell.expected, &groups)
|
||||
} else {
|
||||
match id {
|
||||
InvariantId::Screen => {
|
||||
check_screen(cell.session, baseline, marker_after, &groups)
|
||||
}
|
||||
InvariantId::Quiet => check_quiet(quiet_frames),
|
||||
_ => unreachable!("is_log_side covers every other id"),
|
||||
}
|
||||
};
|
||||
(id, result)
|
||||
})
|
||||
.collect();
|
||||
Ok(CellRun {
|
||||
outcomes,
|
||||
streams: groups.len(),
|
||||
})
|
||||
}
|
||||
|
||||
/// I-QUIET: no repaint churn after the last finalize — at most
|
||||
/// [`QUIET_MAX_FRAMES`] frames land in the watermark window.
|
||||
fn check_quiet(quiet_frames: u64) -> InvariantResult {
|
||||
if quiet_frames > QUIET_MAX_FRAMES {
|
||||
return InvariantResult::Violated {
|
||||
detail: format!(
|
||||
"{quiet_frames} frames painted in the {QUIET_WINDOW:?} post-finalize window \
|
||||
(> {QUIET_MAX_FRAMES}) — repaint churn after the gesture ended"
|
||||
),
|
||||
};
|
||||
}
|
||||
InvariantResult::Pass
|
||||
}
|
||||
|
||||
/// Net signed lines a stream delivered: its last flush-bearing record's
|
||||
/// cumulative `applied_total` (finalize when present, else the last flush of
|
||||
/// a trailing in-flight stream).
|
||||
fn stream_applied(group: &StreamGroup<'_>) -> i64 {
|
||||
group
|
||||
.flush_bearing()
|
||||
.last()
|
||||
.map_or(0, |record| record.applied_total)
|
||||
}
|
||||
|
||||
/// Replay per-stream deliveries through the viewport's clamps: `0` is the
|
||||
/// bottom pin (down-deliveries there don't move — G7's point), and the
|
||||
/// return is clamped to `-baseline` because travel above marker 0 pins the
|
||||
/// topmost visible marker at 0.
|
||||
fn simulate_clamped_travel(baseline: usize, applied: impl IntoIterator<Item = i64>) -> i64 {
|
||||
let mut pos: i64 = 0;
|
||||
for delta in applied {
|
||||
pos = (pos + delta).min(0);
|
||||
}
|
||||
pos.max(-(baseline as i64))
|
||||
}
|
||||
|
||||
/// I-SCREEN: the viewport visibly moved/clamped exactly as the recorder
|
||||
/// says it should have — topmost-marker delta equals the bottom-clamped
|
||||
/// replay of the per-stream `applied_total`s (up is negative, matching the
|
||||
/// producer's `ScrollDirection` sign and the marker index direction).
|
||||
fn check_screen(
|
||||
session: SessionKind,
|
||||
baseline: usize,
|
||||
marker_after: Option<usize>,
|
||||
groups: &[StreamGroup<'_>],
|
||||
) -> InvariantResult {
|
||||
if session == SessionKind::Streaming {
|
||||
// The live bottom keeps growing mid-stream, so "marker delta ==
|
||||
// applied" has no stable frame of reference — a cell-table bug.
|
||||
return InvariantResult::Violated {
|
||||
detail: "I-SCREEN attached to a streaming session (no stable baseline)".to_owned(),
|
||||
};
|
||||
}
|
||||
let Some(after) = marker_after else {
|
||||
return InvariantResult::Violated {
|
||||
detail: "no marker visible after the gesture".to_owned(),
|
||||
};
|
||||
};
|
||||
let expected =
|
||||
baseline as i64 + simulate_clamped_travel(baseline, groups.iter().map(stream_applied));
|
||||
if after as i64 != expected {
|
||||
return InvariantResult::Violated {
|
||||
detail: format!(
|
||||
"topmost marker {baseline} -> {after} after the gesture, but the clamped \
|
||||
replay of the streams' applied totals lands at {expected}"
|
||||
),
|
||||
};
|
||||
}
|
||||
InvariantResult::Pass
|
||||
}
|
||||
|
||||
/// XPASS row detail: the actionable half of the xfail contract.
|
||||
const XPASS_DETAIL: &str = "expected to violate (xfail) but PASSED — the pinned bug got fixed \
|
||||
or the cell rotted; promote the invariant out of the xfail set";
|
||||
|
||||
/// Classify evaluated invariants into per-row statuses and the cell verdict.
|
||||
/// Precedence: any `Fail` (non-xfail violation) fails the cell; else any
|
||||
/// `XPass` fails it (fixed/rotted xfail must be promoted, not absorbed);
|
||||
/// else any `XFail` marks the expected failure; else `Pass`.
|
||||
fn classify(
|
||||
outcomes: &[(InvariantId, InvariantResult)],
|
||||
xfail: &[InvariantId],
|
||||
) -> (CellStatus, Vec<InvariantReport>) {
|
||||
let rows: Vec<InvariantReport> = outcomes
|
||||
.iter()
|
||||
.map(|(id, result)| {
|
||||
let expected_to_fail = xfail.contains(id);
|
||||
let (status, detail) = match (result, expected_to_fail) {
|
||||
(InvariantResult::Pass, false) => (InvariantStatus::Pass, None),
|
||||
(InvariantResult::Pass, true) => {
|
||||
(InvariantStatus::XPass, Some(XPASS_DETAIL.to_owned()))
|
||||
}
|
||||
(InvariantResult::Violated { detail }, false) => {
|
||||
(InvariantStatus::Fail, Some(detail.clone()))
|
||||
}
|
||||
(InvariantResult::Violated { detail }, true) => {
|
||||
(InvariantStatus::XFail, Some(detail.clone()))
|
||||
}
|
||||
};
|
||||
InvariantReport {
|
||||
id: id.as_str().to_owned(),
|
||||
status,
|
||||
detail,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let has = |status: InvariantStatus| rows.iter().any(|row| row.status == status);
|
||||
let status = if has(InvariantStatus::Fail) {
|
||||
CellStatus::Fail
|
||||
} else if has(InvariantStatus::XPass) {
|
||||
CellStatus::XPass
|
||||
} else if has(InvariantStatus::XFail) {
|
||||
CellStatus::XFail
|
||||
} else {
|
||||
CellStatus::Pass
|
||||
};
|
||||
(status, rows)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn violated(detail: &str) -> InvariantResult {
|
||||
InvariantResult::Violated {
|
||||
detail: detail.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The wire bytes of the shared wheel position, pinned to the
|
||||
/// `40;12` encoding documented on `WHEEL_ROW`/`WHEEL_COL`.
|
||||
#[test]
|
||||
fn sgr_report_encodes_one_based_wire_coords() {
|
||||
assert_eq!(sgr_wheel_report(64), "\x1b[<64;40;12M");
|
||||
assert_eq!(sgr_wheel_report(65), "\x1b[<65;40;12M");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clamped_travel_models_bottom_pin_and_top_clamp() {
|
||||
// G7's shape: down-deliveries at the pin don't move, the up tail does.
|
||||
assert_eq!(simulate_clamped_travel(300, [10, -7]), -7);
|
||||
// Up then partially back down: net movement, no clamp involved.
|
||||
assert_eq!(simulate_clamped_travel(300, [-5, 3]), -2);
|
||||
// Pure down at the pin stays put.
|
||||
assert_eq!(simulate_clamped_travel(300, [10]), 0);
|
||||
// Down past the pin then up: the overshoot must not bank as credit.
|
||||
assert_eq!(simulate_clamped_travel(300, [25, -4]), -4);
|
||||
// Travel beyond the transcript top pins the topmost marker at 0.
|
||||
assert_eq!(simulate_clamped_travel(30, [-500]), -30);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quiet_allows_the_straggler_allowance_only() {
|
||||
assert!(check_quiet(0).is_pass());
|
||||
assert!(check_quiet(QUIET_MAX_FRAMES).is_pass());
|
||||
let result = check_quiet(QUIET_MAX_FRAMES + 1);
|
||||
assert!(
|
||||
matches!(result, InvariantResult::Violated { ref detail } if detail.contains("churn"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn screen_rejects_streaming_sessions_and_marker_loss() {
|
||||
let streaming = check_screen(SessionKind::Streaming, 100, Some(100), &[]);
|
||||
assert!(
|
||||
matches!(streaming, InvariantResult::Violated { ref detail } if detail.contains("streaming"))
|
||||
);
|
||||
let lost = check_screen(SessionKind::BottomPinned, 100, None, &[]);
|
||||
assert!(
|
||||
matches!(lost, InvariantResult::Violated { ref detail } if detail.contains("no marker"))
|
||||
);
|
||||
// Empty capture ⇒ no movement expected; a matching marker passes.
|
||||
assert!(check_screen(SessionKind::BottomPinned, 100, Some(100), &[]).is_pass());
|
||||
let moved = check_screen(SessionKind::BottomPinned, 100, Some(97), &[]);
|
||||
assert!(matches!(moved, InvariantResult::Violated { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_precedence_fail_over_xpass_over_xfail_over_pass() {
|
||||
use InvariantId::{Cap, NoDrop, Ord, SmoothCoast};
|
||||
|
||||
// All pass, nothing xfailed → Pass.
|
||||
let (status, rows) = classify(&[(Ord, InvariantResult::Pass)], &[]);
|
||||
assert_eq!(status, CellStatus::Pass);
|
||||
assert_eq!(rows[0].status, InvariantStatus::Pass);
|
||||
assert_eq!(rows[0].id, "I-ORD");
|
||||
|
||||
// The declared bug violates, everything else passes → XFail.
|
||||
let jerk = [
|
||||
(Ord, InvariantResult::Pass),
|
||||
(SmoothCoast, violated("coast")),
|
||||
(NoDrop, violated("dropped 74")),
|
||||
];
|
||||
let (status, rows) = classify(&jerk, &[SmoothCoast, NoDrop]);
|
||||
assert_eq!(status, CellStatus::XFail);
|
||||
assert_eq!(rows[1].status, InvariantStatus::XFail);
|
||||
assert_eq!(rows[1].detail.as_deref(), Some("coast"));
|
||||
|
||||
// One xfail row passing flips the cell to XPass (fixed-bug tripwire)…
|
||||
let half_fixed = [
|
||||
(SmoothCoast, InvariantResult::Pass),
|
||||
(NoDrop, violated("dropped 74")),
|
||||
];
|
||||
let (status, rows) = classify(&half_fixed, &[SmoothCoast, NoDrop]);
|
||||
assert_eq!(status, CellStatus::XPass);
|
||||
assert!(rows[0].detail.as_deref().unwrap().contains("promote"));
|
||||
|
||||
// …but any non-xfail violation dominates everything.
|
||||
let broken = [
|
||||
(Cap, violated("flushed 40 exceeds cap 25")),
|
||||
(SmoothCoast, InvariantResult::Pass),
|
||||
];
|
||||
let (status, _) = classify(&broken, &[SmoothCoast]);
|
||||
assert_eq!(status, CellStatus::Fail);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tier_labels_match_the_report_vocabulary() {
|
||||
assert_eq!(tier_label(Tier::Curated), "curated");
|
||||
assert_eq!(tier_label(Tier::Full), "full");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
//! Marker-session preambles: spawn the pager into the primed state a matrix
|
||||
//! cell's gesture assumes, ported from the pager's `tests/pty_e2e/scroll.rs`
|
||||
//! (`spawn_bottom_pinned_marker_scrollback[_with_env]` /
|
||||
//! `spawn_streaming_marker_turn`) onto this crate's public API so the A13
|
||||
//! runner — which lives here, not in the pager's test tree — can reuse the
|
||||
//! proven construction.
|
||||
//!
|
||||
//! ## Controller-drop footgun (kept from the originals)
|
||||
//!
|
||||
//! Destructure the returned controller into a LIVE binding (`content` /
|
||||
//! `_content`), never `_` — a `_` binding drops it immediately, killing the
|
||||
//! mock server mid-session and surfacing as a confusing 60s stream timeout
|
||||
//! instead of an obvious failure.
|
||||
//!
|
||||
//! ## Gate-release ownership (streaming sessions)
|
||||
//!
|
||||
//! [`spawn_streaming_marker_session`] holds every agent completion
|
||||
//! (`hold_agent_completions`) and paces deltas, so the turn provably cannot
|
||||
//! finish while the gesture runs. The CALLER owns the release: call
|
||||
//! `content.release_agent_completions()` after the gesture (before quitting,
|
||||
//! so the pager exits a completed turn rather than an aborted stream).
|
||||
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::PtyHarness;
|
||||
use crate::content::ContentController;
|
||||
|
||||
/// Transcript state a cell's gesture starts from.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum SessionKind {
|
||||
/// Response fully streamed, viewport bottom-pinned, scrollback focused.
|
||||
Settled,
|
||||
/// Same preamble as [`SessionKind::Settled`] — the settle leaves the
|
||||
/// viewport pinned to the bottom. A separate kind because the cell's
|
||||
/// point is the pin itself (down-scroll must clamp: G7 / I-SCREEN),
|
||||
/// not just "there is history above".
|
||||
BottomPinned,
|
||||
/// Turn still streaming (paced deltas + held completion gate) when the
|
||||
/// gesture runs — mid-stream by construction (G8).
|
||||
Streaming,
|
||||
}
|
||||
|
||||
/// PTY geometry shared by every matrix session (the pager e2e default:
|
||||
/// large enough that the welcome screen never wraps).
|
||||
pub const SESSION_ROWS: u16 = 50;
|
||||
pub const SESSION_COLS: u16 = 120;
|
||||
|
||||
/// Wheel-report position, 0-based (row, col): inside the scrollback pane at
|
||||
/// the [`SESSION_ROWS`]×[`SESSION_COLS`] PTY (wire encodes as SGR `40;12`).
|
||||
pub const WHEEL_ROW: u16 = 11;
|
||||
pub const WHEEL_COL: u16 = 39;
|
||||
|
||||
/// Welcome-screen sentinel: the menu label `Quit` (case-sensitive, so the
|
||||
/// lowercase authenticating hint can't match early).
|
||||
const WELCOME_SCREEN_SENTINEL: &str = "Quit";
|
||||
const WELCOME_TIMEOUT: Duration = Duration::from_secs(20);
|
||||
/// Prompt submitted to the agent — short enough never to wrap.
|
||||
const PROMPT: &str = "go";
|
||||
/// First line inside the marker code block (the mock response sentinel).
|
||||
const MOCK_RESPONSE_SENTINEL: &str = "MOCKRESPONSE";
|
||||
|
||||
/// Prefix shared by [`marker_line`] and the screen parses.
|
||||
const MARKER_PREFIX: &str = "MARKER-";
|
||||
|
||||
/// End marker of a streaming session's tail: the final streamed word, kept
|
||||
/// off-screen while the tail is in flight (witness for "still streaming").
|
||||
pub const STREAM_END_SENTINEL: &str = "STREAMDONE";
|
||||
|
||||
/// Unique numbered marker line (`MARKER-0042`), zero-padded so no marker is
|
||||
/// a substring of another within a [`marker_response`] transcript.
|
||||
pub fn marker_line(n: usize) -> String {
|
||||
format!("{MARKER_PREFIX}{n:04}")
|
||||
}
|
||||
|
||||
/// `count` numbered marker lines inside a fenced code block — markdown
|
||||
/// renders exactly one row per marker with no soft-wrap reflow, so marker
|
||||
/// index deltas equal row deltas.
|
||||
pub fn marker_response(count: usize) -> String {
|
||||
let mut s = String::with_capacity(count * 16 + 64);
|
||||
s.push_str("```\n");
|
||||
s.push_str(MOCK_RESPONSE_SENTINEL);
|
||||
s.push('\n');
|
||||
for i in 0..count {
|
||||
s.push_str(&marker_line(i));
|
||||
s.push('\n');
|
||||
}
|
||||
s.push_str("```\n");
|
||||
s
|
||||
}
|
||||
|
||||
/// Current screen row (0-based) of `marker`, or `None` when off-screen.
|
||||
pub fn marker_screen_row(harness: &PtyHarness, marker: &str) -> Option<u16> {
|
||||
harness
|
||||
.screen_contents()
|
||||
.lines()
|
||||
.position(|line| line.contains(marker))
|
||||
.map(|row| row as u16)
|
||||
}
|
||||
|
||||
/// Index of the topmost marker on screen (`None` when none visible;
|
||||
/// malformed hits skipped). Scrolling UP strictly decreases it by the rows
|
||||
/// scrolled — the "viewport moved by K rows" primitive (I-SCREEN's input).
|
||||
pub fn topmost_visible_marker(harness: &PtyHarness) -> Option<usize> {
|
||||
topmost_marker_in(&harness.screen_contents())
|
||||
}
|
||||
|
||||
fn topmost_marker_in(screen: &str) -> Option<usize> {
|
||||
screen.lines().find_map(|line| {
|
||||
let digits_at = line.find(MARKER_PREFIX)? + MARKER_PREFIX.len();
|
||||
line.get(digits_at..digits_at + 4)?.parse().ok()
|
||||
})
|
||||
}
|
||||
|
||||
/// Streaming-session defaults: the marker block rides the FIRST delta; a
|
||||
/// 240-word tail at 30ms per SSE event then streams for ≥7s — wider than
|
||||
/// any gesture table (G9b, the longest, spans ~0.4s) plus its finalize
|
||||
/// wait, so the gesture provably overlaps the stream. Values proven by the
|
||||
/// pager's `wheel_*_mid_stream` e2e tests.
|
||||
pub const STREAMING_TAIL_WORDS: usize = 240;
|
||||
pub const STREAMING_CHUNK_DELAY: Duration = Duration::from_millis(30);
|
||||
|
||||
/// Spawn `binary` (the pager under test — the matrix runner plumbs its
|
||||
/// `--binary` override here; tests pass `env::pager_binary()`) for `kind`
|
||||
/// over a `marker_count`-marker transcript with `extra_env` appended to the
|
||||
/// mock's pager env (terminal-class markers, `KIGI_SCROLL_*`,
|
||||
/// `KIGI_SCROLL_LOG` — the PTY spawn strips host-terminal identity first,
|
||||
/// so injected markers always win).
|
||||
///
|
||||
/// Returns `(harness, controller, baseline)` where `baseline` is the
|
||||
/// topmost visible marker index — see the footgun notes in the module docs
|
||||
/// before binding the controller.
|
||||
pub async fn spawn_marker_session(
|
||||
binary: &Path,
|
||||
kind: SessionKind,
|
||||
marker_count: usize,
|
||||
extra_env: &[(&str, &str)],
|
||||
) -> (PtyHarness, ContentController, usize) {
|
||||
match kind {
|
||||
SessionKind::Settled | SessionKind::BottomPinned => {
|
||||
spawn_settled_marker_session(binary, marker_count, extra_env).await
|
||||
}
|
||||
SessionKind::Streaming => {
|
||||
spawn_streaming_marker_session(
|
||||
binary,
|
||||
marker_count,
|
||||
STREAMING_TAIL_WORDS,
|
||||
STREAMING_CHUNK_DELAY,
|
||||
extra_env,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared spawn: mock content up, pager in a [`SESSION_ROWS`]×
|
||||
/// [`SESSION_COLS`] PTY with content env + `extra_env`, welcome waited.
|
||||
fn spawn_pager(
|
||||
binary: &Path,
|
||||
content: &ContentController,
|
||||
extra_env: &[(&str, &str)],
|
||||
) -> PtyHarness {
|
||||
let content_env = content.env_for_pager();
|
||||
let mut env: Vec<(&str, &str)> = content_env
|
||||
.iter()
|
||||
.map(|(k, v)| (k.as_str(), v.as_str()))
|
||||
.collect();
|
||||
env.extend_from_slice(extra_env);
|
||||
let mut harness = PtyHarness::new(binary, SESSION_ROWS, SESSION_COLS, &[], &env)
|
||||
.expect("spawn pager with content");
|
||||
harness
|
||||
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
|
||||
.expect("welcome text");
|
||||
harness
|
||||
.inject_keys(format!("{PROMPT}\r").as_bytes())
|
||||
.expect("submit prompt");
|
||||
harness
|
||||
}
|
||||
|
||||
/// Panic (with the screen) unless the transcript overflows the viewport:
|
||||
/// marker 0 off-screen-top and some later marker visible — otherwise the
|
||||
/// gesture has nothing to scroll into view. Returns the movement baseline.
|
||||
fn assert_scrollable_baseline(harness: &PtyHarness, context: &str) -> usize {
|
||||
assert!(
|
||||
marker_screen_row(harness, &marker_line(0)).is_none(),
|
||||
"setup: {} already visible → transcript not taller than the screen ({context})\nscreen:\n{}",
|
||||
marker_line(0),
|
||||
harness.screen_contents()
|
||||
);
|
||||
topmost_visible_marker(harness).unwrap_or_else(|| {
|
||||
panic!(
|
||||
"setup: no marker visible ({context})\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// [`SessionKind::Settled`]/[`SessionKind::BottomPinned`] preamble: stream
|
||||
/// the whole marker transcript, land bottom-pinned in follow mode, focus
|
||||
/// the scrollback via Tab (Esc would silently arm the rewind picker, whose
|
||||
/// ~800ms expiry redraw pollutes frame captures; wheel reports are
|
||||
/// position-routed regardless of focus), quiesce, reset the frame-timing
|
||||
/// watermark — counted frames then come only from the caller's gesture.
|
||||
pub async fn spawn_settled_marker_session(
|
||||
binary: &Path,
|
||||
marker_count: usize,
|
||||
extra_env: &[(&str, &str)],
|
||||
) -> (PtyHarness, ContentController, usize) {
|
||||
let content = ContentController::start().await.expect("start content");
|
||||
content.set_response(marker_response(marker_count));
|
||||
let mut harness = spawn_pager(binary, &content, extra_env);
|
||||
|
||||
// The LAST marker is the last thing streamed → the whole transcript is in.
|
||||
harness
|
||||
.wait_for_text(&marker_line(marker_count - 1), Duration::from_secs(60))
|
||||
.expect("response finished streaming");
|
||||
harness.update(Duration::from_millis(500));
|
||||
|
||||
let baseline = assert_scrollable_baseline(&harness, "settled");
|
||||
|
||||
harness.inject_keys(b"\t").expect("focus scrollback (tab)");
|
||||
harness.update(Duration::from_millis(500));
|
||||
// Footer wait is best-effort (skin-dependent), as in the original.
|
||||
let _ = harness.wait_for_text("Space:prompt", Duration::from_secs(5));
|
||||
|
||||
harness.update(Duration::from_millis(300));
|
||||
harness.reset_timing();
|
||||
|
||||
(harness, content, baseline)
|
||||
}
|
||||
|
||||
/// [`SessionKind::Streaming`] preamble: the whole fenced marker block rides
|
||||
/// the first delta (the mock splits deltas on single spaces and the block
|
||||
/// contains none), the space-separated tail streams word-by-word at
|
||||
/// `chunk_delay`, and the completion gate holds the turn's terminal event —
|
||||
/// mid-turn by construction until the caller releases the gate (see the
|
||||
/// module docs). Setup guards: transcript overflows the viewport and
|
||||
/// [`STREAM_END_SENTINEL`] is not on screen (bottom-pinned follow would
|
||||
/// render it if the stream had finished).
|
||||
pub async fn spawn_streaming_marker_session(
|
||||
binary: &Path,
|
||||
marker_count: usize,
|
||||
tail_words: usize,
|
||||
chunk_delay: Duration,
|
||||
extra_env: &[(&str, &str)],
|
||||
) -> (PtyHarness, ContentController, usize) {
|
||||
let content = ContentController::start().await.expect("start content");
|
||||
content.set_chunk_delay(Some(chunk_delay));
|
||||
content.hold_agent_completions();
|
||||
// set_turns (not set_response): only agent turns ride the completion
|
||||
// gate; aux title/classifier requests fall through untouched.
|
||||
let mut turn = marker_response(marker_count);
|
||||
for i in 0..tail_words {
|
||||
turn.push_str(&format!("TAIL-{i:04} "));
|
||||
}
|
||||
turn.push_str(STREAM_END_SENTINEL);
|
||||
content.set_turns([turn]);
|
||||
|
||||
let mut harness = spawn_pager(binary, &content, extra_env);
|
||||
// The last marker rides the first delta, so this waits only for the
|
||||
// stream to START; the tail is still in flight afterwards.
|
||||
harness
|
||||
.wait_for_text(&marker_line(marker_count - 1), Duration::from_secs(30))
|
||||
.expect("marker block streamed");
|
||||
harness.update(Duration::from_millis(300));
|
||||
|
||||
assert!(
|
||||
!harness.contains_text(STREAM_END_SENTINEL),
|
||||
"setup: {STREAM_END_SENTINEL} already on screen — the paced tail finished \
|
||||
before the gesture could overlap the stream\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
let baseline = assert_scrollable_baseline(&harness, "mid-stream");
|
||||
|
||||
(harness, content, baseline)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn marker_transcript_shape() {
|
||||
assert_eq!(marker_line(42), "MARKER-0042");
|
||||
let response = marker_response(3);
|
||||
assert!(response.starts_with("```\nMOCKRESPONSE\n"));
|
||||
assert!(response.ends_with("MARKER-0002\n```\n"));
|
||||
assert_eq!(response.matches(MARKER_PREFIX).count(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn topmost_marker_parses_top_down_and_skips_malformed_hits() {
|
||||
let screen = "noise\nMARKER-12 truncated\n x MARKER-0007 y\nMARKER-0003\n";
|
||||
assert_eq!(topmost_marker_in(screen), Some(7));
|
||||
assert_eq!(topmost_marker_in("no markers here"), None);
|
||||
}
|
||||
|
||||
/// The streaming defaults must dwarf the longest gesture: every step
|
||||
/// table finishes (delays summed) well inside the paced tail's window,
|
||||
/// or the "still streaming" guard could race the gesture.
|
||||
#[test]
|
||||
fn streaming_window_covers_every_gesture_table() {
|
||||
use super::super::gestures::{GestureId, STREAM_GAP_MS};
|
||||
let tail_ms = STREAMING_TAIL_WORDS as u64 * STREAMING_CHUNK_DELAY.as_millis() as u64;
|
||||
for gesture in GestureId::ALL {
|
||||
for ept in [1u16, 3] {
|
||||
let span: u64 = gesture.steps(ept).iter().map(|s| s.pre_delay_ms).sum();
|
||||
// Gesture + every stream's finalize gap, ×4 headroom.
|
||||
let with_finalize = (span + gesture.expected_streams() as u64 * STREAM_GAP_MS) * 4;
|
||||
assert!(
|
||||
with_finalize < tail_ms,
|
||||
"{gesture:?} (ept={ept}) span {with_finalize}ms crowds the \
|
||||
{tail_ms}ms streaming window"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
//! Layer 2b: Frame timing via VTE parser.
|
||||
//!
|
||||
//! Detects frame boundaries from synchronized-update markers
|
||||
//! (`CSI ? 2026 h/l`) emitted by crossterm's `BeginSynchronizedUpdate` /
|
||||
//! `EndSynchronizedUpdate`, and records wall-clock timing for each frame.
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
// Use `vte` re-exported from `alacritty_terminal` (via ptyctl) instead of
|
||||
// a separate direct dependency.
|
||||
use alacritty_terminal::vte;
|
||||
|
||||
/// Timing data for a single rendered frame.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FrameTiming {
|
||||
/// Wall-clock duration from BeginSynchronizedUpdate to EndSynchronizedUpdate.
|
||||
pub duration: Duration,
|
||||
/// Number of printable characters emitted within this frame.
|
||||
pub chars: usize,
|
||||
}
|
||||
|
||||
/// Parses raw PTY output for synchronized-update frame boundaries and
|
||||
/// records per-frame timing data.
|
||||
///
|
||||
/// Composes a `vte::Parser` with an internal `FrameTimingHandler` that
|
||||
/// implements `vte::Perform`.
|
||||
pub struct FrameTimingParser {
|
||||
vte_parser: vte::Parser,
|
||||
handler: FrameTimingHandler,
|
||||
}
|
||||
|
||||
impl FrameTimingParser {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
vte_parser: vte::Parser::new(),
|
||||
handler: FrameTimingHandler::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Feed raw PTY output bytes, parsing for frame boundaries.
|
||||
pub fn feed(&mut self, bytes: &[u8]) {
|
||||
self.vte_parser.advance(&mut self.handler, bytes);
|
||||
}
|
||||
|
||||
/// Return all recorded frame timings.
|
||||
pub fn timings(&self) -> &[FrameTiming] {
|
||||
&self.handler.timings
|
||||
}
|
||||
|
||||
/// Return the total number of completed frames.
|
||||
pub fn frame_count(&self) -> u64 {
|
||||
self.handler.timings.len() as u64
|
||||
}
|
||||
|
||||
/// Reset all recorded timing data.
|
||||
pub fn reset(&mut self) {
|
||||
self.handler.timings.clear();
|
||||
self.handler.frame_start = None;
|
||||
self.handler.current_frame_chars = 0;
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for FrameTimingParser {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Internal VTE `Perform` implementation that detects frame boundaries.
|
||||
struct FrameTimingHandler {
|
||||
frame_start: Option<Instant>,
|
||||
timings: Vec<FrameTiming>,
|
||||
current_frame_chars: usize,
|
||||
}
|
||||
|
||||
impl FrameTimingHandler {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
frame_start: None,
|
||||
timings: Vec::new(),
|
||||
current_frame_chars: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl vte::Perform for FrameTimingHandler {
|
||||
fn csi_dispatch(
|
||||
&mut self,
|
||||
params: &vte::Params,
|
||||
intermediates: &[u8],
|
||||
_ignore: bool,
|
||||
action: char,
|
||||
) {
|
||||
// Only interested in CSI ? <param> h/l (private mode set/reset).
|
||||
if intermediates != b"?" {
|
||||
return;
|
||||
}
|
||||
let param_val: u16 = params
|
||||
.iter()
|
||||
.next()
|
||||
.and_then(|p| p.first().copied())
|
||||
.unwrap_or(0);
|
||||
|
||||
if param_val == 2026 {
|
||||
match action {
|
||||
'h' => {
|
||||
// BeginSynchronizedUpdate: frame starts.
|
||||
self.frame_start = Some(Instant::now());
|
||||
self.current_frame_chars = 0;
|
||||
}
|
||||
'l' => {
|
||||
// EndSynchronizedUpdate: frame ends.
|
||||
if let Some(start) = self.frame_start.take() {
|
||||
self.timings.push(FrameTiming {
|
||||
duration: start.elapsed(),
|
||||
chars: self.current_frame_chars,
|
||||
});
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn print(&mut self, _c: char) {
|
||||
self.current_frame_chars += 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
//! Empty-composer Enter force-sends the top mid-turn queued follow-up.
|
||||
//!
|
||||
//! ```bash
|
||||
//! cargo test -p kigi-pager-pty-harness --test empty_enter_send_now -- --ignored --nocapture
|
||||
//! ```
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore] // opt-in: real pager binary in a PTY (CI runs with --ignored)
|
||||
async fn empty_enter_force_sends_top_queued() {
|
||||
kigi_pager_pty_harness::scenarios::empty_enter_send_now::assert_empty_enter_force_sends_top_queued()
|
||||
.await
|
||||
.expect("empty Enter mid-turn must force-send the top queued follow-up");
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
//! Integration test: the shell re-parks `exit_plan_mode` on
|
||||
//! resume, so approval chrome reappears after quit/`--continue` and approving
|
||||
//! leaves plan mode + starts the implement turn.
|
||||
//!
|
||||
//! CI stages the pager binary via `PAGER_BINARY`. Also runs under plain cargo
|
||||
//! (which builds the pager on demand):
|
||||
//!
|
||||
//! ```bash
|
||||
//! cargo test -p kigi-pager-pty-harness --test plan_approval_resume -- --nocapture
|
||||
//! ```
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn plan_approval_restored_after_resume() {
|
||||
kigi_pager_pty_harness::scenarios::plan_approval_resume::assert_plan_approval_restored_after_resume()
|
||||
.await
|
||||
.expect("shell must re-park exit_plan_mode on resume so approval chrome returns");
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
//! E2E: a submitted prompt is durably recorded in the per-CWD
|
||||
//! `prompt_history.jsonl` and survives quitting the TUI — via a fast double
|
||||
//! Ctrl+C (the reported repro, recalled after a `--continue` resume) and via a
|
||||
//! real OS SIGINT routed through the same graceful quit.
|
||||
//!
|
||||
//! Drives the real pager binary through a PTY against the shared mock
|
||||
//! inference server (isolated `$HOME`), exercising the full
|
||||
//! pager -> shell -> queue_input -> append path plus the graceful-quit teardown.
|
||||
//!
|
||||
//! Coverage note: both paths wait for the turn to land before quitting, so
|
||||
//! `queue_input` (and its now-awaited append) has already run. This is an
|
||||
//! end-to-end durability + recall check, not a probe of the old detached-append
|
||||
//! race — that race is closed structurally by awaiting the append in
|
||||
//! `queue_input` and is covered by the `prompt_history` unit test. The
|
||||
//! deterministic regression catch here is the SIGINT path exiting 0 (pre-fix it
|
||||
//! was `process::exit(130)`).
|
||||
//!
|
||||
//! ```bash
|
||||
//! cargo test -p kigi-pager-pty-harness --test prompt_history_durable_quit \
|
||||
//! -- --ignored --nocapture
|
||||
//! ```
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use kigi_pager_pty_harness::{ContentController, PtyHarness, keys, pager_binary};
|
||||
|
||||
const ROWS: u16 = 50;
|
||||
const COLS: u16 = 120;
|
||||
const CANARY: &str = "REGRESSIONCANARY42";
|
||||
#[cfg(unix)]
|
||||
const SIGINT_CANARY: &str = "SIGINTCANARY7";
|
||||
const ACK: &str = "ACKSENTINEL";
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore] // opt-in: spawns the real pager binary in a PTY (CI runs with --ignored)
|
||||
async fn prompt_history_durable_after_double_ctrl_c_and_recallable_on_resume() {
|
||||
run().await.expect("prompt-history durable-quit e2e");
|
||||
}
|
||||
|
||||
/// A real OS SIGINT (not an injected Ctrl+C key byte) must route through the
|
||||
/// same graceful quit: the prompt stays durable and the process exits 0.
|
||||
#[cfg(unix)]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore] // opt-in: spawns the real pager binary in a PTY (CI runs with --ignored)
|
||||
async fn prompt_history_durable_after_real_sigint_graceful_quit() {
|
||||
run_sigint().await.expect("sigint graceful-quit e2e");
|
||||
}
|
||||
|
||||
async fn run() -> Result<()> {
|
||||
let content = ContentController::start()
|
||||
.await
|
||||
.context("start mock server")?;
|
||||
content.set_response(format!("{ACK} acknowledged."));
|
||||
|
||||
let project = tempfile::tempdir().context("project dir")?;
|
||||
std::fs::create_dir_all(project.path().join(".git")).context("create .git")?;
|
||||
|
||||
let binary = pager_binary().context("resolve pager binary")?;
|
||||
|
||||
// 1) Submit a prompt and let the turn settle (proves the shell reached
|
||||
// queue_input, where the append happens), then quit with double Ctrl+C.
|
||||
let mut first = submit_and_settle(&binary, &content, project.path(), CANARY)
|
||||
.context("submit canary in first pager")?;
|
||||
|
||||
// Double Ctrl+C: first arms the quit confirmation on the empty prompt, the
|
||||
// second confirms -> same graceful Action::Quit as `/exit`.
|
||||
let pre = first.raw_output().len();
|
||||
first.inject_keys(keys::CTRL_C).context("ctrl-c arm")?;
|
||||
first.update(Duration::from_millis(250));
|
||||
first.inject_keys(keys::CTRL_C).context("ctrl-c confirm")?;
|
||||
|
||||
// Drain output until the child exits so the post-`pre` suffix holds the full
|
||||
// graceful teardown (incl. the show-cursor restore) for the assertions below.
|
||||
first.update(Duration::from_secs(10));
|
||||
|
||||
let code = first.wait_exit_code(Duration::from_secs(10));
|
||||
assert_eq!(
|
||||
code,
|
||||
Some(0),
|
||||
"double Ctrl+C should exit via the graceful quit (exit 0), got {code:?}"
|
||||
);
|
||||
assert!(
|
||||
terminal_restored(&first, pre),
|
||||
"terminal not restored (no show-cursor after quit) on the double-Ctrl+C path"
|
||||
);
|
||||
drop(first);
|
||||
|
||||
// 2) Durability: the prompt must be on disk after the quit.
|
||||
assert_prompt_durable(content.home(), CANARY)?;
|
||||
|
||||
// 3) Recall across restart: `--continue` resumes the session and replays it.
|
||||
let mut resumed = PtyHarness::spawn_with_content_in_dir(
|
||||
&binary,
|
||||
ROWS,
|
||||
COLS,
|
||||
&content,
|
||||
&["--continue"],
|
||||
Some(project.path()),
|
||||
)
|
||||
.context("spawn resumed pager")?;
|
||||
resumed
|
||||
.wait_for_text(CANARY, Duration::from_secs(20))
|
||||
.context("resumed session replayed the prior prompt")?;
|
||||
assert!(
|
||||
!resumed.contains_text("panicked"),
|
||||
"pager panicked on resume:\n{}",
|
||||
resumed.screen_contents()
|
||||
);
|
||||
|
||||
// Up-arrow opens the history overlay; smoke-check it doesn't crash and the
|
||||
// recalled prompt stays reachable.
|
||||
resumed.inject_keys(keys::UP).context("press Up")?;
|
||||
resumed.update(Duration::from_millis(500));
|
||||
assert!(
|
||||
resumed.contains_text(CANARY),
|
||||
"canary not reachable after Up:\n{}",
|
||||
resumed.screen_contents()
|
||||
);
|
||||
assert!(
|
||||
!resumed.contains_text("panicked"),
|
||||
"pager panicked after Up:\n{}",
|
||||
resumed.screen_contents()
|
||||
);
|
||||
|
||||
resumed.quit().context("quit resumed pager")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Real-SIGINT variant of [`run`]: deliver an OS signal to the pager child and
|
||||
/// assert the same graceful-quit outcome (durable prompt, clean exit).
|
||||
#[cfg(unix)]
|
||||
async fn run_sigint() -> Result<()> {
|
||||
let content = ContentController::start()
|
||||
.await
|
||||
.context("start mock server")?;
|
||||
content.set_response(format!("{ACK} acknowledged."));
|
||||
|
||||
let project = tempfile::tempdir().context("project dir")?;
|
||||
std::fs::create_dir_all(project.path().join(".git")).context("create .git")?;
|
||||
|
||||
let binary = pager_binary().context("resolve pager binary")?;
|
||||
|
||||
let mut first = submit_and_settle(&binary, &content, project.path(), SIGINT_CANARY)
|
||||
.context("submit canary before SIGINT")?;
|
||||
|
||||
// A real SIGINT, not an injected 0x03 key byte (raw mode delivers that as a
|
||||
// key event — the double-Ctrl+C path above), drives the OS-signal path.
|
||||
let pre = first.raw_output().len();
|
||||
first.send_signal(libc::SIGINT).context("send SIGINT")?;
|
||||
|
||||
// Drain output until the child exits so the post-`pre` suffix holds the full
|
||||
// graceful teardown (incl. the show-cursor restore) for the assertions below.
|
||||
first.update(Duration::from_secs(10));
|
||||
|
||||
// Pre-fix the SIGINT handler called std::process::exit(130); routing it
|
||||
// through the graceful quit exits 0 — the deterministic Part-B regression catch.
|
||||
let code = first.wait_exit_code(Duration::from_secs(10));
|
||||
assert_eq!(
|
||||
code,
|
||||
Some(0),
|
||||
"real SIGINT should route through the graceful quit (exit 0), got {code:?}"
|
||||
);
|
||||
assert!(
|
||||
terminal_restored(&first, pre),
|
||||
"terminal not restored (no show-cursor after SIGINT) on the SIGINT path"
|
||||
);
|
||||
drop(first);
|
||||
|
||||
// Durability: the prompt must be on disk despite the signal-driven quit.
|
||||
assert_prompt_durable(content.home(), SIGINT_CANARY)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Spawn the pager in `project`, submit `canary`, then wait for the turn to
|
||||
/// render + settle to idle. Waiting past `queue_input` is deliberate: it
|
||||
/// guarantees the prompt reached the shell (so the durability assertion is
|
||||
/// meaningful), at the cost of not reproducing the sub-millisecond
|
||||
/// detached-append race (closed by awaiting the append; see the unit test).
|
||||
fn submit_and_settle(
|
||||
binary: &Path,
|
||||
content: &ContentController,
|
||||
project: &Path,
|
||||
canary: &str,
|
||||
) -> Result<PtyHarness> {
|
||||
let mut pager =
|
||||
PtyHarness::spawn_with_content_in_dir(binary, ROWS, COLS, content, &[], Some(project))
|
||||
.context("spawn pager")?;
|
||||
pager
|
||||
.wait_for_text("Quit", Duration::from_secs(20))
|
||||
.context("welcome screen")?;
|
||||
pager
|
||||
.inject_keys(canary.as_bytes())
|
||||
.context("type canary")?;
|
||||
pager.inject_keys(keys::ENTER).context("submit prompt")?;
|
||||
pager
|
||||
.wait_for_text(ACK, Duration::from_secs(30))
|
||||
.context("turn response rendered")?;
|
||||
pager.update(Duration::from_millis(1000)); // let the short turn finish (idle)
|
||||
Ok(pager)
|
||||
}
|
||||
|
||||
/// Whether the pager emitted the show-cursor restore (`ESC [ ?25h`) after byte
|
||||
/// offset `since`. Scanning only the post-quit suffix avoids matching the
|
||||
/// show-cursor that normal rendering emits mid-session.
|
||||
fn terminal_restored(h: &PtyHarness, since: usize) -> bool {
|
||||
const SHOW_CURSOR: &[u8] = b"\x1b[?25h";
|
||||
let raw = h.raw_output();
|
||||
raw[since.min(raw.len())..]
|
||||
.windows(SHOW_CURSOR.len())
|
||||
.any(|w| w == SHOW_CURSOR)
|
||||
}
|
||||
|
||||
/// Assert the per-CWD `prompt_history.jsonl` durably recorded `canary`.
|
||||
fn assert_prompt_durable(home: &Path, canary: &str) -> Result<()> {
|
||||
let hist = find_prompt_history(home).context("locate prompt_history.jsonl")?;
|
||||
let body =
|
||||
std::fs::read_to_string(&hist).with_context(|| format!("read {}", hist.display()))?;
|
||||
eprintln!("[e2e] prompt_history.jsonl @ {}:\n{body}", hist.display());
|
||||
assert!(
|
||||
body.contains(canary),
|
||||
"prompt_history.jsonl is missing the submitted prompt after the quit:\n{body}"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The per-CWD history file lives at `<home>/.kigi/sessions/<enc-cwd>/prompt_history.jsonl`.
|
||||
fn find_prompt_history(home: &Path) -> Result<PathBuf> {
|
||||
let root = home.join(".kigi").join("sessions");
|
||||
for cwd_ent in std::fs::read_dir(&root).with_context(|| format!("read {}", root.display()))? {
|
||||
let cwd_ent = cwd_ent?;
|
||||
if !cwd_ent.file_type()?.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let candidate = cwd_ent.path().join("prompt_history.jsonl");
|
||||
if candidate.is_file() {
|
||||
return Ok(candidate);
|
||||
}
|
||||
}
|
||||
bail!("no prompt_history.jsonl found under {}", root.display())
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
//! PTY scroll correctness via the harness (screen state from **ptyctl** /
|
||||
//! alacritty_terminal).
|
||||
//!
|
||||
//! After a large mock response settles (follow-mode pins the viewport to the
|
||||
//! bottom), scroll **up** then **down** and assert the visible markers move.
|
||||
//! That path exercises the optimized AllTurns paint window in production.
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use kigi_pager_pty_harness::{ContentController, PtyHarness, keys, pager_binary};
|
||||
|
||||
const ROWS: u16 = 40;
|
||||
const COLS: u16 = 100;
|
||||
const LINES: usize = 400;
|
||||
|
||||
/// SGR mouse-wheel at (row, col) — 1-indexed terminal coords for CSI.
|
||||
fn sgr_scroll(button: u16, row: u16, col: u16, count: u16) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
for _ in 0..count {
|
||||
out.extend_from_slice(format!("\x1b[<{button};{col};{row}M").as_bytes());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn long_markdown(n: usize) -> String {
|
||||
let mut out = String::with_capacity(n * 96);
|
||||
out.push_str("# Scroll correctness fixture\n\n");
|
||||
out.push_str("MARKER_TOP_OF_RESPONSE unique-alpha-token\n\n");
|
||||
for i in 0..n {
|
||||
out.push_str(&format!("scroll-marker-{i:04} lorem line body text here\n"));
|
||||
}
|
||||
out.push_str("\nMARKER_BOTTOM_OF_RESPONSE unique-omega-token\n");
|
||||
out
|
||||
}
|
||||
|
||||
fn any_marker(s: &str, range: std::ops::Range<usize>) -> bool {
|
||||
range
|
||||
.clone()
|
||||
.any(|i| s.contains(&format!("scroll-marker-{i:04}")))
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn scroll_up_from_follow_bottom_then_back_down() -> Result<()> {
|
||||
let binary = pager_binary().context("resolve pager binary")?;
|
||||
let content = ContentController::start()
|
||||
.await
|
||||
.context("start mock inference server")?;
|
||||
content.set_response(long_markdown(LINES));
|
||||
|
||||
let mut harness = PtyHarness::spawn_with_content(&binary, ROWS, COLS, &content, &[])
|
||||
.context("spawn pager in PTY")?;
|
||||
|
||||
harness
|
||||
.wait_for_text("Quit", Duration::from_secs(20))
|
||||
.context("welcome")?;
|
||||
|
||||
harness.inject_keys(b"scroll test\r")?;
|
||||
harness
|
||||
.wait_for_text("MARKER_TOP_OF_RESPONSE", Duration::from_secs(30))
|
||||
.context("top marker while streaming")?;
|
||||
|
||||
// Wait for stream end. Follow mode pins the viewport to the bottom.
|
||||
let settle_deadline = Instant::now() + Duration::from_secs(45);
|
||||
loop {
|
||||
harness.update(Duration::from_millis(100));
|
||||
let s = harness.screen_contents();
|
||||
let settled = !s.contains("Responding")
|
||||
&& (s.contains("MARKER_BOTTOM_OF_RESPONSE") || s.contains("Turn completed"));
|
||||
if settled {
|
||||
break;
|
||||
}
|
||||
if Instant::now() > settle_deadline {
|
||||
bail!(
|
||||
"timed out waiting for response to settle; screen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
}
|
||||
}
|
||||
harness.update(Duration::from_millis(400));
|
||||
|
||||
let at_bottom = harness.screen_contents();
|
||||
assert!(
|
||||
at_bottom.contains("MARKER_BOTTOM_OF_RESPONSE")
|
||||
|| any_marker(&at_bottom, 350..400)
|
||||
|| at_bottom.contains("Turn completed"),
|
||||
"follow mode should land near bottom after stream; screen:\n{at_bottom}"
|
||||
);
|
||||
let bottom_snapshot = at_bottom.clone();
|
||||
|
||||
// PageUp/PageDown plus wheel exercise both scroll directions.
|
||||
let wall = Instant::now();
|
||||
for _ in 0..30 {
|
||||
harness.inject_keys(keys::PGUP)?;
|
||||
harness.update(Duration::from_millis(35));
|
||||
if !harness.is_running() {
|
||||
bail!("pager exited while PageUp scrolling");
|
||||
}
|
||||
}
|
||||
// Wheel over mid-screen scrollback (1-indexed SGR coords).
|
||||
harness.inject_keys(&sgr_scroll(64, 15, 40, 50))?; // 64 = wheel up
|
||||
harness.update(Duration::from_millis(200));
|
||||
harness.update(Duration::from_millis(300));
|
||||
let mid = harness.screen_contents();
|
||||
let scroll_up_ms = wall.elapsed().as_millis();
|
||||
|
||||
assert_ne!(
|
||||
mid, bottom_snapshot,
|
||||
"screen must change after scrolling up; still:\n{mid}"
|
||||
);
|
||||
assert!(
|
||||
!mid.contains("MARKER_BOTTOM_OF_RESPONSE"),
|
||||
"bottom marker should leave viewport after scroll-up; screen:\n{mid}"
|
||||
);
|
||||
// Mid/early markers become visible; not only the last 50 lines.
|
||||
assert!(
|
||||
any_marker(&mid, 0..340),
|
||||
"expected earlier scroll-markers after scroll-up; screen:\n{mid}"
|
||||
);
|
||||
|
||||
// Scroll back down toward the end.
|
||||
for _ in 0..35 {
|
||||
harness.inject_keys(keys::PGDN)?;
|
||||
harness.update(Duration::from_millis(35));
|
||||
if !harness.is_running() {
|
||||
bail!("pager exited while PageDown scrolling");
|
||||
}
|
||||
}
|
||||
harness.inject_keys(&sgr_scroll(65, 15, 40, 50))?; // 65 = wheel down
|
||||
harness.update(Duration::from_millis(200));
|
||||
harness.update(Duration::from_millis(400));
|
||||
let back_bottom = harness.screen_contents();
|
||||
|
||||
assert!(
|
||||
back_bottom.contains("MARKER_BOTTOM_OF_RESPONSE")
|
||||
|| any_marker(&back_bottom, 340..400)
|
||||
|| back_bottom.contains("Turn completed"),
|
||||
"expected to return near bottom after scroll-down; screen:\n{back_bottom}"
|
||||
);
|
||||
assert_ne!(
|
||||
back_bottom, mid,
|
||||
"screen after scroll-down must differ from mid-scroll snapshot"
|
||||
);
|
||||
|
||||
eprintln!(
|
||||
"ptyctl scroll ok: up_wall_ms={scroll_up_ms} bottom_len={} mid_len={} back_len={}",
|
||||
bottom_snapshot.len(),
|
||||
mid.len(),
|
||||
back_bottom.len()
|
||||
);
|
||||
|
||||
let _ = harness.quit();
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
//! Curated scroll-matrix tier: one test per curated cell, exact-filterable
|
||||
//! by cell id (`cargo test --test scroll_matrix_curated c5_tmux_g9a -- --exact`).
|
||||
//!
|
||||
//! Each test drives `scroll_matrix::run_cell` against the real pager binary
|
||||
//! (PAGER_BINARY / local debug build — same contract as
|
||||
//! `scroll_correctness_ptyctl.rs`, and like it NOT `#[ignore]`d). Cells are
|
||||
//! host-paced PTY sessions, so a process-wide lock serializes them: the
|
||||
//! default in-process test parallelism would stretch gesture gaps and stack
|
||||
//! eight pagers onto one machine.
|
||||
//!
|
||||
//! Artifacts (recorder captures + per-cell report.json rows) land under
|
||||
//! `$TMPDIR/scroll-matrix-curated/` and are kept for post-mortems.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use kigi_pager_pty_harness::pager_binary;
|
||||
use kigi_pager_pty_harness::scroll_matrix::{
|
||||
CellReport, CellStatus, curated, run_cell, summary_table,
|
||||
};
|
||||
|
||||
/// Serializes cells across the in-process test threads (tokio mutex: held
|
||||
/// across awaits; no poisoning, so one failed cell doesn't cascade).
|
||||
static SERIAL: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
|
||||
|
||||
fn artifacts_dir() -> PathBuf {
|
||||
std::env::temp_dir().join("scroll-matrix-curated")
|
||||
}
|
||||
|
||||
/// Run one curated cell by id and return its report (panics on unknown ids
|
||||
/// so a renamed cell can't leave a vacuous test behind).
|
||||
async fn run_curated_cell(cell_id: &str) -> CellReport {
|
||||
let cell = curated()
|
||||
.find(|cell| cell.id == cell_id)
|
||||
.unwrap_or_else(|| panic!("{cell_id} is not a curated cell — update this test file"));
|
||||
let binary = pager_binary().expect("resolve pager binary");
|
||||
let _serial = SERIAL.lock().await;
|
||||
let report = run_cell(cell, &binary, &artifacts_dir()).await;
|
||||
eprintln!(
|
||||
"{}",
|
||||
summary_table(std::slice::from_ref(&report)).trim_end()
|
||||
);
|
||||
report
|
||||
}
|
||||
|
||||
/// Assert a normal (no-xfail) curated cell passes outright.
|
||||
async fn assert_cell_passes(cell_id: &str) {
|
||||
let report = run_curated_cell(cell_id).await;
|
||||
assert_eq!(
|
||||
report.status,
|
||||
CellStatus::Pass,
|
||||
"{cell_id} did not pass — capture kept at {}\n{}",
|
||||
report.log_path,
|
||||
serde_json::to_string_pretty(&report).expect("report json"),
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn c1_auto_g3_flood_speed100() {
|
||||
assert_cell_passes("c1_auto_g3_flood_speed100").await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn c2_auto_g3_flood_speed100() {
|
||||
assert_cell_passes("c2_auto_g3_flood_speed100").await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn c3_wheel_lines1_g1() {
|
||||
assert_cell_passes("c3_wheel_lines1_g1").await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn c4_auto_g10_ambiguous() {
|
||||
assert_cell_passes("c4_auto_g10_ambiguous").await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn c5_tmux_g9a() {
|
||||
assert_cell_passes("c5_tmux_g9a").await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn c5_tmux_g9b() {
|
||||
assert_cell_passes("c5_tmux_g9b").await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn c1_auto_g8_midstream() {
|
||||
assert_cell_passes("c1_auto_g8_midstream").await;
|
||||
}
|
||||
|
||||
/// The formerly-declared bug: the finalize-decel fix landed, so the G4 jerk
|
||||
/// cell passes outright — I-SMOOTH-COAST (post-input motion at most one
|
||||
/// tapered cap) and I-NO-DROP (finalize discards nothing) moved from the
|
||||
/// xfail set to ordinary pass rows. The cell id keeps its historical name
|
||||
/// for artifact continuity.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn c1_auto_g4_jerk_xfail() {
|
||||
assert_cell_passes("c1_auto_g4_jerk_xfail").await;
|
||||
}
|
||||
|
||||
/// Tripwire: every curated cell has a test above (a row added to the
|
||||
/// curated tier without a matching test would otherwise silently skip CI).
|
||||
#[test]
|
||||
fn curated_cells_all_have_a_test() {
|
||||
let covered = [
|
||||
"c1_auto_g3_flood_speed100",
|
||||
"c2_auto_g3_flood_speed100",
|
||||
"c3_wheel_lines1_g1",
|
||||
"c4_auto_g10_ambiguous",
|
||||
"c5_tmux_g9a",
|
||||
"c5_tmux_g9b",
|
||||
"c1_auto_g8_midstream",
|
||||
"c1_auto_g4_jerk_xfail",
|
||||
];
|
||||
let curated_ids: Vec<&str> = curated().map(|cell| cell.id).collect();
|
||||
assert_eq!(
|
||||
curated_ids, covered,
|
||||
"curated tier changed — add/remove the matching #[tokio::test] fns in this file"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user