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).
636 lines
25 KiB
Rust
636 lines
25 KiB
Rust
//! 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)
|
|
}
|
|
}
|