docs(comments): rewrite comments across all crates to the guidelines
Sweep every first-party crate source (1956 .rs files) to the project comment guidelines: delete redundant restatements, decorative banners, change narration, and end-of-line comments; keep and tighten the crucial ones (invariants, bug rationale, SAFETY blocks, ported-source attribution). No functional code changed. Every edit is proven comment-only against the prior tree by a comment-stripping lexer (string/char/raw-string aware) plus a separate doctest-fence check. Where removing a comment made rustfmt or clippy want to re-lay-out adjacent code, the minimal triggering comment is restored so code tokens stay byte-identical. Gates green: cargo fmt --all --check (0 diffs), cargo check and cargo clippy --workspace --all-targets (0 warnings). Adds scripts/check_codegen_comment_guidelines.py — the enforcement gate for these guidelines (flags banners, end-of-line comments, change narration, and commented-out code).
This commit is contained in:
@@ -1,11 +1,10 @@
|
||||
//! `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`).
|
||||
//! Runs matrix cells (`scroll_matrix::CELLS`) against a real pager binary in a
|
||||
//! PTY 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 one-off cell reruns
|
||||
//! (`--filter`).
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::process::ExitCode;
|
||||
@@ -113,8 +112,8 @@ async fn run() -> Result<ExitCode> {
|
||||
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.
|
||||
/// Reports come back in table order regardless of completion order. `jobs == 1`
|
||||
/// runs inline, which keeps gesture timing representative.
|
||||
async fn run_cells(
|
||||
cells: &[&'static MatrixCell],
|
||||
jobs: usize,
|
||||
|
||||
@@ -187,7 +187,7 @@ impl ContentController {
|
||||
self.server.request_bodies()
|
||||
}
|
||||
|
||||
// ── Mock storage controls (park-on-401 e2e) ────────────────────────────
|
||||
// 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) {
|
||||
|
||||
@@ -9,7 +9,6 @@ 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))
|
||||
|
||||
@@ -19,7 +19,6 @@ 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");
|
||||
@@ -152,7 +151,6 @@ pub fn set_clipboard_png(path: &Path) -> Result<()> {
|
||||
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>> =
|
||||
|
||||
@@ -2,14 +2,10 @@
|
||||
//! 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
|
||||
//! 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.
|
||||
//! SAME leader.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, Instant};
|
||||
@@ -29,8 +25,6 @@ pub struct LeaderCluster {
|
||||
}
|
||||
|
||||
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
|
||||
@@ -50,21 +44,19 @@ impl LeaderCluster {
|
||||
})
|
||||
}
|
||||
|
||||
/// Spawn the leader-electing client (`--leader --leader-socket <S>` plus
|
||||
/// `extra_args`); it starts a fresh session and brings up the leader.
|
||||
/// Spawn the client that 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`).
|
||||
/// leader.
|
||||
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.
|
||||
/// `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];
|
||||
@@ -74,22 +66,18 @@ impl LeaderCluster {
|
||||
.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`).
|
||||
/// Root of `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).
|
||||
/// The `params.update` payload of every record across every
|
||||
/// `updates.jsonl` under the cluster, so a caller can match on the
|
||||
/// `sessionUpdate` tag directly.
|
||||
///
|
||||
/// 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`
|
||||
@@ -99,7 +87,6 @@ impl LeaderCluster {
|
||||
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));
|
||||
}
|
||||
@@ -107,10 +94,8 @@ impl LeaderCluster {
|
||||
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).
|
||||
/// Poll [`session_updates`](Self::session_updates) until a
|
||||
/// `turn_completed` record appears, or error on timeout.
|
||||
pub fn wait_for_turn_completed(&self, timeout: Duration) -> Result<Value> {
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
@@ -140,18 +125,14 @@ impl LeaderCluster {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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`].)
|
||||
/// Parse the `params.update` payload out of each line of an `updates.jsonl`
|
||||
/// body, whose on-disk shape is `{..,"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 rather than failing the batch.
|
||||
fn parse_update_payloads(text: &str) -> Vec<Value> {
|
||||
text.lines()
|
||||
.filter_map(|line| {
|
||||
|
||||
@@ -200,7 +200,7 @@ impl PtyHarness {
|
||||
Self::new_in_dir(binary, rows, cols, extra_args, &env_refs, cwd)
|
||||
}
|
||||
|
||||
// ── PTY control ──────────────────────────────────────────────────
|
||||
// PTY control
|
||||
|
||||
/// Inject raw key bytes into the PTY.
|
||||
pub fn inject_keys(&mut self, keys: &[u8]) -> Result<()> {
|
||||
@@ -214,7 +214,7 @@ impl PtyHarness {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Update: receive PTY output inline → feed both parsers ────────
|
||||
// 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.
|
||||
@@ -273,7 +273,7 @@ impl PtyHarness {
|
||||
self.pty.is_running()
|
||||
}
|
||||
|
||||
// ── Screen state queries ─────────────────────────────────────────
|
||||
// Screen state queries
|
||||
|
||||
/// Return structured plain-text screen contents.
|
||||
pub fn screen_output(&self) -> ScreenOutput {
|
||||
@@ -463,7 +463,7 @@ impl PtyHarness {
|
||||
std::fs::write(path, out).with_context(|| format!("write cast {}", path.display()))
|
||||
}
|
||||
|
||||
// ── Scrollback queries (minimal mode commits blocks into native history) ──
|
||||
// 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 {
|
||||
@@ -542,7 +542,7 @@ impl PtyHarness {
|
||||
self.screen.cursor_position()
|
||||
}
|
||||
|
||||
// ── Frame timing queries ─────────────────────────────────────────
|
||||
// Frame timing queries
|
||||
|
||||
/// Return all recorded frame timings.
|
||||
pub fn frame_timings(&self) -> &[FrameTiming] {
|
||||
@@ -564,7 +564,7 @@ impl PtyHarness {
|
||||
self.timing.reset();
|
||||
}
|
||||
|
||||
// ── Lifecycle ────────────────────────────────────────────────────
|
||||
// Lifecycle
|
||||
|
||||
/// Send 'q' and wait for the child process to exit (5s timeout, then kill).
|
||||
pub fn quit(&mut self) -> Result<()> {
|
||||
|
||||
@@ -37,7 +37,8 @@ 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().
|
||||
// Kept alive to hold the PTY open; used by resize().
|
||||
#[allow(dead_code)]
|
||||
master: Box<dyn portable_pty::MasterPty + Send>,
|
||||
}
|
||||
|
||||
|
||||
@@ -83,7 +83,7 @@ impl BenchResults {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Baseline comparison ────────────────────────────────────────────────────
|
||||
// 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.
|
||||
|
||||
@@ -45,8 +45,6 @@ fn all_user_message_blobs(content: &ContentController) -> Vec<String> {
|
||||
.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
|
||||
@@ -79,13 +77,11 @@ pub async fn assert_empty_enter_force_sends_top_queued() -> Result<()> {
|
||||
.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.
|
||||
// Release the gate so a completion racing the abort 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.
|
||||
// The "❯ " prefix distinguishes the committed prompt block from the
|
||||
// prefix-less queue row.
|
||||
harness
|
||||
.wait_for_text(
|
||||
"\u{276F} please also check the logs",
|
||||
@@ -96,8 +92,6 @@ pub async fn assert_empty_enter_force_sends_top_queued() -> Result<()> {
|
||||
.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{}",
|
||||
|
||||
@@ -12,8 +12,7 @@ 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).
|
||||
// Let the splash screen's fade animation settle before timing idle frames.
|
||||
harness.update(Duration::from_secs(1));
|
||||
harness.reset_timing();
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
//! `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.
|
||||
//! `large_codeblock` — render a large syntax-highlighted Rust code block and
|
||||
//! scroll through it. Stresses the `syntect` highlighting cache, wrapping of
|
||||
//! long source lines, and the ScratchBuffer copy for a single oversized entry.
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
//! `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.
|
||||
//! `mixed_interaction` — scroll while streaming, the real-world worst case.
|
||||
//! Stresses simultaneous cache invalidation (from streaming) and full viewport
|
||||
//! re-render (from scrolling), surfacing `dirty_heights` / scroll-offset
|
||||
//! interactions.
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
@@ -16,8 +15,7 @@ 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.
|
||||
// Same payload shape as `streaming_render`.
|
||||
let mut body = String::from("mixed-bench ");
|
||||
for i in 0..TARGET_WORDS {
|
||||
body.push_str("tok");
|
||||
|
||||
@@ -21,7 +21,6 @@ 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 {
|
||||
@@ -40,7 +39,6 @@ pub enum Scenario {
|
||||
}
|
||||
|
||||
impl Scenario {
|
||||
/// Every scenario, in dispatch order.
|
||||
pub const ALL: &'static [Scenario] = &[
|
||||
Scenario::ScrollStress,
|
||||
Scenario::StreamingRender,
|
||||
@@ -62,7 +60,6 @@ impl Scenario {
|
||||
}
|
||||
}
|
||||
|
||||
/// Dispatch to the scenario implementation.
|
||||
pub async fn run(
|
||||
self,
|
||||
harness: &mut PtyHarness,
|
||||
@@ -79,11 +76,10 @@ impl Scenario {
|
||||
}
|
||||
}
|
||||
|
||||
/// Wait for the pager to render the initial welcome screen. All scenarios
|
||||
/// that prompt / stream content rely on the pager being past startup.
|
||||
/// Scenarios that prompt or stream content require the pager to be 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`).
|
||||
// Capital-Q menu label appears on the normal welcome and on gate menus — see
|
||||
// `render_menu` in `kigi-tui` `views/welcome/mod.rs`.
|
||||
harness
|
||||
.wait_for_text("Quit", Duration::from_secs(15))
|
||||
.map_err(|e| anyhow::anyhow!("pager failed to reach welcome screen: {e}"))
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
//! 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.
|
||||
//! Without the shell re-park no reverse-request reaches the resumed pager, so no
|
||||
//! approval chrome appears and this scenario fails.
|
||||
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
@@ -21,8 +21,8 @@ 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.
|
||||
/// Turn 1 seeds the session before quit; turn 2 is the implement turn the shell
|
||||
/// injects once the resumed approval is approved.
|
||||
const SETUP_SENTINEL: &str = "GBT3703SETUP";
|
||||
const IMPLEMENT_SENTINEL: &str = "GBT3703IMPLEMENTED";
|
||||
|
||||
@@ -35,15 +35,12 @@ const PLAN_BODY: &str = "\
|
||||
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.
|
||||
// One response per agent turn, FIFO; only 2+-tool requests draw from the
|
||||
// queue, so aux requests never steal a turn.
|
||||
content.set_turns([
|
||||
format!("{SETUP_SENTINEL}: drafted a plan for the user to review."),
|
||||
format!("{IMPLEMENT_SENTINEL}: implementing the approved plan."),
|
||||
@@ -90,10 +87,8 @@ pub async fn assert_plan_approval_restored_after_resume() -> Result<()> {
|
||||
)
|
||||
.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.
|
||||
// Approval chrome can open immediately and cover chat history, so key on the
|
||||
// chrome markers rather than SETUP_SENTINEL, which the plan viewer may hide.
|
||||
resumed
|
||||
.wait_for_text("request changes", WELCOME_TIMEOUT)
|
||||
.context("restored approval 'request changes' after --continue")?;
|
||||
@@ -104,8 +99,8 @@ pub async fn assert_plan_approval_restored_after_resume() -> Result<()> {
|
||||
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.
|
||||
// Any one of these proves the session was restored: with chrome covering the
|
||||
// transcript the plan body from disk is often the only visible evidence.
|
||||
if !screen.contains("GBT3703Repro")
|
||||
&& !screen.contains(SETUP_SENTINEL)
|
||||
&& !screen.contains("Seed plan file on disk")
|
||||
@@ -116,7 +111,6 @@ pub async fn assert_plan_approval_restored_after_resume() -> Result<()> {
|
||||
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))
|
||||
@@ -126,9 +120,8 @@ pub async fn assert_plan_approval_restored_after_resume() -> Result<()> {
|
||||
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.
|
||||
/// Parks a plan approval in every session dir under the sandbox home, since the
|
||||
/// id of the session the first pager created is not known here.
|
||||
fn seed_parked_approval(home: &Path) -> Result<usize> {
|
||||
let sessions_root = home.join(".kigi").join("sessions");
|
||||
if !sessions_root.is_dir() {
|
||||
@@ -163,12 +156,9 @@ fn seed_parked_approval(home: &Path) -> Result<usize> {
|
||||
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.
|
||||
/// The shape mirrors `kigi_shell::session::plan_mode::PlanModeSnapshot`, but this
|
||||
/// test-only harness edits it as raw JSON rather than pull in the heavy shell
|
||||
/// crate; preserving unknown fields keeps that decoupling safe as the schema grows.
|
||||
fn write_awaiting_plan_mode(path: &Path) -> Result<()> {
|
||||
let mut value: serde_json::Value = std::fs::read_to_string(path)
|
||||
.ok()
|
||||
|
||||
@@ -18,7 +18,6 @@ pub async fn run(harness: &mut PtyHarness, _content: &ContentController) -> Resu
|
||||
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)?;
|
||||
|
||||
@@ -19,7 +19,6 @@ pub async fn run(harness: &mut PtyHarness, content: &ContentController) -> Resul
|
||||
// 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.
|
||||
|
||||
@@ -18,15 +18,13 @@ pub async fn run(harness: &mut PtyHarness, content: &ContentController) -> Resul
|
||||
|
||||
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
|
||||
// Wait for the first delta to hit the screen so the measurement covers 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.
|
||||
// No pacing needed here: the mock server paces the stream over HTTP/SSE.
|
||||
let start = Instant::now();
|
||||
while start.elapsed() < STREAM_WINDOW {
|
||||
harness.update(Duration::from_millis(100));
|
||||
@@ -41,8 +39,8 @@ pub async fn run(harness: &mut PtyHarness, content: &ContentController) -> Resul
|
||||
|
||||
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.
|
||||
// Leading sentinel that `wait_for_text` keys on, so it lands in the first
|
||||
// few deltas of the stream.
|
||||
s.push_str("stream-bench ");
|
||||
for i in 0..words {
|
||||
s.push_str("word");
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Layer 2a: Screen state tracking via `alacritty_terminal` (ptyctl).
|
||||
//! 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.
|
||||
@@ -6,8 +6,6 @@
|
||||
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
|
||||
@@ -18,7 +16,6 @@ pub struct ScreenTracker {
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -28,14 +25,11 @@ impl ScreenTracker {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// Replies queued while parsing fed input, concatenated in order.
|
||||
///
|
||||
/// These MUST be written back to the PTY or programs that probe the
|
||||
/// terminal will hang or time out — most relevant here, the inline
|
||||
@@ -51,23 +45,22 @@ impl ScreenTracker {
|
||||
out
|
||||
}
|
||||
|
||||
/// Return structured screen contents (no escape codes).
|
||||
/// Structured screen contents, with escape codes stripped.
|
||||
pub fn output(&self) -> ScreenOutput {
|
||||
self.terminal.screen_content(&ScreenOpts::default())
|
||||
}
|
||||
|
||||
/// Return the full text contents of the screen (no escape codes).
|
||||
/// Full screen text, with escape codes stripped.
|
||||
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).
|
||||
/// Cursor position as `(row, col)`, 0-indexed to match the vt100
|
||||
/// convention the tests are written against.
|
||||
pub fn cursor_position(&self) -> (u16, u16) {
|
||||
let pos = self.terminal.cursor_position();
|
||||
// ptyctl cursor is 1-indexed; the harness API is 0-indexed.
|
||||
@@ -77,35 +70,32 @@ impl ScreenTracker {
|
||||
)
|
||||
}
|
||||
|
||||
/// 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.).
|
||||
/// Escape hatch for queries this wrapper does not expose (scrollback
|
||||
/// details, terminal modes, …).
|
||||
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`).
|
||||
/// Number of history lines that have 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.
|
||||
/// Scrollback history as text, oldest line first.
|
||||
pub fn scrollback_text(&self) -> String {
|
||||
let n = self.terminal.scrollback_count();
|
||||
self.terminal
|
||||
@@ -116,8 +106,7 @@ impl ScreenTracker {
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
/// Scrollback history plus the visible screen, joined oldest→newest:
|
||||
/// everything a user could see by scrolling up. Minimal-mode committed
|
||||
/// Scrollback plus visible screen, oldest→newest. 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 {
|
||||
@@ -130,7 +119,6 @@ impl ScreenTracker {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether scrollback + visible screen contains `text`.
|
||||
pub fn full_contains(&self, text: &str) -> bool {
|
||||
self.full_text().contains(text)
|
||||
}
|
||||
@@ -140,36 +128,28 @@ impl ScreenTracker {
|
||||
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.
|
||||
/// Without a forwardable CPR reply the inline viewport's startup cursor
|
||||
/// query never completes and `--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");
|
||||
|
||||
@@ -18,8 +18,7 @@ const SGR_LEFT_BUTTON: u16 = 0;
|
||||
const SGR_MIDDLE_BUTTON: u16 = 1;
|
||||
const SGR_RIGHT_BUTTON: u16 = 2;
|
||||
const SGR_DRAG_BUTTON: u16 = 32;
|
||||
/// SGR wheel button codes (bit 6 / +64 marks a wheel event). Public so PTY
|
||||
/// tests share this single definition instead of respelling 64/65.
|
||||
/// SGR wheel button codes (bit 6 / +64 marks a wheel event).
|
||||
pub const SGR_SCROLL_UP: u16 = 64;
|
||||
pub const SGR_SCROLL_DOWN: u16 = 65;
|
||||
|
||||
@@ -38,10 +37,8 @@ pub struct ScriptedScenario {
|
||||
pub terminal: TerminalConfig,
|
||||
#[serde(default)]
|
||||
pub environment: EnvironmentConfig,
|
||||
/// Optional ephemeral workspace materialized into a temp dir and used as the
|
||||
/// pager's cwd. Lets a scenario exercise repo-local behavior — e.g. the
|
||||
/// folder-trust prompt, which only renders when `cwd` has a repo-local
|
||||
/// config (`.mcp.json`). `None` inherits the test process cwd.
|
||||
/// Ephemeral workspace used as the pager's cwd; `None` inherits the test
|
||||
/// process cwd.
|
||||
#[serde(default)]
|
||||
pub workspace: Option<WorkspaceConfig>,
|
||||
#[serde(default)]
|
||||
@@ -51,7 +48,6 @@ pub struct ScriptedScenario {
|
||||
}
|
||||
|
||||
impl ScriptedScenario {
|
||||
/// Load a scenario from JSON.
|
||||
pub fn from_json_file(path: &Path) -> Result<Self> {
|
||||
let text = fs::read_to_string(path)
|
||||
.with_context(|| format!("read scenario file {}", path.display()))?;
|
||||
@@ -59,7 +55,6 @@ impl ScriptedScenario {
|
||||
.with_context(|| format!("parse scenario JSON {}", path.display()))
|
||||
}
|
||||
|
||||
/// Load a scenario from YAML.
|
||||
pub fn from_yaml_file(path: &Path) -> Result<Self> {
|
||||
let text = fs::read_to_string(path)
|
||||
.with_context(|| format!("read scenario file {}", path.display()))?;
|
||||
@@ -67,7 +62,6 @@ impl ScriptedScenario {
|
||||
.with_context(|| format!("parse scenario YAML {}", path.display()))
|
||||
}
|
||||
|
||||
/// Load a scenario from JSON or YAML based on file extension.
|
||||
pub fn from_file(path: &Path) -> Result<Self> {
|
||||
match path.extension().and_then(|ext| ext.to_str()) {
|
||||
Some("yaml" | "yml") => Self::from_yaml_file(path),
|
||||
@@ -86,7 +80,6 @@ impl ScriptedScenario {
|
||||
}
|
||||
}
|
||||
|
||||
/// Terminal dimensions for a scripted run.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TerminalConfig {
|
||||
#[serde(default = "default_rows")]
|
||||
@@ -113,7 +106,6 @@ impl Default for TerminalConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// Environment filters and extra environment variables for a scenario.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct EnvironmentConfig {
|
||||
/// Optional OS allow-list. Values are Rust `std::env::consts::OS` strings
|
||||
@@ -124,10 +116,8 @@ pub struct EnvironmentConfig {
|
||||
/// `std::env::consts::ARCH` strings such as `aarch64` or `x86_64`.
|
||||
#[serde(default)]
|
||||
pub arch: Vec<String>,
|
||||
/// Additional env vars set on the pager process.
|
||||
#[serde(default)]
|
||||
pub env: Vec<EnvVar>,
|
||||
/// Extra CLI args passed to the pager binary.
|
||||
#[serde(default)]
|
||||
pub args: Vec<String>,
|
||||
/// Optional `config.toml` written into the run's isolated `$KIGI_SHARE_DIR`
|
||||
@@ -144,7 +134,6 @@ impl EnvironmentConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// One environment variable assignment.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EnvVar {
|
||||
pub key: String,
|
||||
@@ -216,11 +205,9 @@ impl Default for MockConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// A single executable scenario step.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "action", rename_all = "snake_case")]
|
||||
pub enum ScenarioStep {
|
||||
/// Wait until the screen contains text.
|
||||
WaitForText {
|
||||
text: String,
|
||||
#[serde(default = "default_wait_timeout_ms")]
|
||||
@@ -230,7 +217,7 @@ pub enum ScenarioStep {
|
||||
AssertContains { text: String },
|
||||
/// Assert text is absent from the current screen.
|
||||
AssertNotContains { text: String },
|
||||
/// Type literal text into the TUI.
|
||||
/// Type literal text, not the key notation [`ScenarioStep::Keys`] parses.
|
||||
TypeText { text: String },
|
||||
/// Inject keys using ptyctl notation, for example `<Enter>`, `<Esc>`, `jj`.
|
||||
Keys { keys: String },
|
||||
@@ -257,11 +244,10 @@ pub enum ScenarioStep {
|
||||
#[serde(default)]
|
||||
button: MouseButton,
|
||||
},
|
||||
/// Simulate a double-click at one coordinate.
|
||||
/// Coordinates are 0-indexed.
|
||||
DoubleClick { row: u16, col: u16 },
|
||||
/// Simulate a triple-click at one coordinate.
|
||||
/// Coordinates are 0-indexed.
|
||||
TripleClick { row: u16, col: u16 },
|
||||
/// Simulate mouse-wheel scrolling at a coordinate.
|
||||
Scroll {
|
||||
row: u16,
|
||||
col: u16,
|
||||
@@ -280,7 +266,6 @@ pub enum ScenarioStep {
|
||||
Drag { from: MousePoint, to: MousePoint },
|
||||
/// Alias for drag used by text-selection scenarios.
|
||||
SelectText { from: MousePoint, to: MousePoint },
|
||||
/// Click visible text by finding it on screen.
|
||||
ClickText {
|
||||
text: String,
|
||||
#[serde(default)]
|
||||
@@ -288,13 +273,11 @@ pub enum ScenarioStep {
|
||||
#[serde(default)]
|
||||
button: MouseButton,
|
||||
},
|
||||
/// Double-click visible text by finding it on screen.
|
||||
DoubleClickText {
|
||||
text: String,
|
||||
#[serde(default)]
|
||||
occurrence: usize,
|
||||
},
|
||||
/// Triple-click visible text by finding it on screen.
|
||||
TripleClickText {
|
||||
text: String,
|
||||
#[serde(default)]
|
||||
@@ -315,7 +298,6 @@ pub enum ScenarioStep {
|
||||
DropTextAt { target: TargetLocator, text: String },
|
||||
/// Simulate drag/drop of image files onto the prompt as terminal paste payload.
|
||||
DropImagesPrompt { images: Vec<String> },
|
||||
/// Trigger a copy shortcut/key sequence from the TUI.
|
||||
Copy {
|
||||
#[serde(default = "default_copy_keys")]
|
||||
keys: String,
|
||||
@@ -359,7 +341,7 @@ pub enum ScenarioStep {
|
||||
/// a Mermaid diagram) was rendered as text, never transmitted as an inline
|
||||
/// image.
|
||||
AssertNoKittyGraphics {},
|
||||
/// Assert that a submitted request included at least this many image payloads.
|
||||
/// Assert a submitted request carried at least `min` image payloads.
|
||||
AssertRequestImageCount { min: usize },
|
||||
/// Assert every submitted image has inline bytes, the expected MIME type, and decodes.
|
||||
AssertInlineImages {
|
||||
@@ -473,7 +455,6 @@ pub enum TargetLocator {
|
||||
},
|
||||
}
|
||||
|
||||
/// Runner configuration for scripted scenarios.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ScriptedRunConfig {
|
||||
pub binary: PathBuf,
|
||||
@@ -563,9 +544,7 @@ impl ScriptedScenarioRunner {
|
||||
.map(String::as_str)
|
||||
.collect();
|
||||
|
||||
// Materialize an optional ephemeral workspace (temp dir + files + git
|
||||
// init) and run the pager there. Bound for the whole run so the dir
|
||||
// outlives the pager process; `None` inherits the test process cwd.
|
||||
// Bound for the whole run so the temp dir outlives the pager process.
|
||||
let workspace_dir = match scenario.workspace.as_ref() {
|
||||
Some(ws) => Some(materialize_workspace(ws)?),
|
||||
None => None,
|
||||
@@ -652,17 +631,13 @@ impl ScriptedScenarioRunner {
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a temp dir for a scenario [`WorkspaceConfig`]: write its files
|
||||
/// (creating parent dirs) and optionally `git init` it. The returned `TempDir`
|
||||
/// must be held for the whole run so the directory outlives the pager process.
|
||||
/// The returned `TempDir` must be held for the whole run so the directory
|
||||
/// outlives the pager process.
|
||||
fn materialize_workspace(workspace: &WorkspaceConfig) -> Result<tempfile::TempDir> {
|
||||
let dir = tempfile::tempdir().context("create scenario workspace temp dir")?;
|
||||
for (rel_path, contents) in &workspace.files {
|
||||
// Fail closed: a `files` key must be a relative path that stays inside
|
||||
// the workspace. Reject absolute paths and any root/prefix/`..`
|
||||
// component so a scenario can never write outside the tempdir. This is
|
||||
// author-controlled test YAML (not a security boundary), but it's the
|
||||
// shared materialization path, so guard it.
|
||||
// Fail closed: reject absolute paths and any root/prefix/`..` component
|
||||
// so a scenario can never write outside the tempdir.
|
||||
let rel = Path::new(rel_path);
|
||||
if rel.is_absolute()
|
||||
|| rel.components().any(|c| {
|
||||
@@ -685,9 +660,7 @@ fn materialize_workspace(workspace: &WorkspaceConfig) -> Result<tempfile::TempDi
|
||||
if workspace.git_init {
|
||||
// A real repo root makes `workspace_key` / repo-local discovery
|
||||
// deterministic regardless of where the system temp dir lives. Shelled
|
||||
// out (not `git2`) because this harness crate has no `git2` dependency;
|
||||
// the subprocess is careful — nulled streams + `status.success()` check
|
||||
// + `bail!` on failure.
|
||||
// out (not `git2`) because this harness crate has no `git2` dependency.
|
||||
let status = std::process::Command::new("git")
|
||||
.args(["init", "-q"])
|
||||
.current_dir(dir.path())
|
||||
@@ -2028,15 +2001,13 @@ mod tests {
|
||||
fn workspace_rejects_paths_outside_the_tempdir() {
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
// A normal relative key (the folder-trust scenario's own `.mcp.json`)
|
||||
// is accepted.
|
||||
let ok = WorkspaceConfig {
|
||||
git_init: false,
|
||||
files: BTreeMap::from([(".mcp.json".to_string(), "{}".to_string())]),
|
||||
};
|
||||
assert!(materialize_workspace(&ok).is_ok());
|
||||
|
||||
// Absolute and `..`-traversing keys are rejected before any write.
|
||||
// Rejected before any write.
|
||||
for bad in ["/etc/evil", "../escape", "sub/../../escape"] {
|
||||
let ws = WorkspaceConfig {
|
||||
git_init: false,
|
||||
@@ -2256,7 +2227,6 @@ mod tests {
|
||||
}]
|
||||
}]
|
||||
})];
|
||||
// exact form still works (default behaviour)
|
||||
assert_inline_images(
|
||||
&bodies,
|
||||
1,
|
||||
@@ -2265,7 +2235,6 @@ mod tests {
|
||||
&DimensionAssertion::Exact(8),
|
||||
)
|
||||
.expect("exact match");
|
||||
// range form succeeds when width >= 1
|
||||
assert_inline_images(
|
||||
&bodies,
|
||||
1,
|
||||
@@ -2280,7 +2249,7 @@ mod tests {
|
||||
},
|
||||
)
|
||||
.expect("range match");
|
||||
// range form fails when width must be >= 28 but actual is 8
|
||||
// The fixture is 8x8, so a `min: 28` range must fail.
|
||||
assert!(
|
||||
assert_inline_images(
|
||||
&bodies,
|
||||
|
||||
@@ -120,7 +120,7 @@ const AUTO_MUX_NODROP: &[InvariantId] = &[
|
||||
];
|
||||
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).
|
||||
/// fix made hold (once this cell's xfail set).
|
||||
const JERK: &[InvariantId] = &[
|
||||
Ord,
|
||||
Cap,
|
||||
@@ -158,7 +158,7 @@ const fn cell(
|
||||
/// The matrix. Ids are `<class>_<config>_<gesture>[_qualifier]`.
|
||||
#[rustfmt::skip]
|
||||
pub const CELLS: &[MatrixCell] = &[
|
||||
// ── Curated (CI tier, 8 cells) ─────────────────────────────────────
|
||||
// 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],
|
||||
@@ -179,7 +179,7 @@ pub const CELLS: &[MatrixCell] = &[
|
||||
// (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) ─
|
||||
// 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,
|
||||
@@ -250,7 +250,8 @@ mod tests {
|
||||
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
|
||||
// one knob overrides both paths
|
||||
trackpad_lpt = lines;
|
||||
}
|
||||
// speed_to_multiplier re-derivation for the settings used in rows.
|
||||
let speed = match get("KIGI_SCROLL_SPEED") {
|
||||
|
||||
@@ -35,9 +35,8 @@ 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).
|
||||
/// `button` is `u16` because the [`SGR_SCROLL_UP`]/[`SGR_SCROLL_DOWN`]
|
||||
/// harness consts are, sparing 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).
|
||||
@@ -190,7 +189,6 @@ pub enum GestureId {
|
||||
}
|
||||
|
||||
impl GestureId {
|
||||
/// Every gesture, for exhaustive table sweeps (tests, the A13 runner).
|
||||
pub const ALL: [GestureId; 12] = [
|
||||
GestureId::G1Notch,
|
||||
GestureId::G2NotchTrain,
|
||||
@@ -256,7 +254,7 @@ impl GestureId {
|
||||
}
|
||||
}
|
||||
|
||||
/// `(up, down)` report counts — direction-sum test primitive.
|
||||
/// `(up, down)` report counts.
|
||||
pub fn direction_counts(steps: &[WheelStep]) -> (usize, usize) {
|
||||
let up = steps.iter().filter(|s| s.button == SGR_SCROLL_UP).count();
|
||||
(up, steps.len() - up)
|
||||
@@ -266,8 +264,6 @@ pub fn direction_counts(steps: &[WheelStep]) -> (usize, usize) {
|
||||
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) {
|
||||
@@ -298,18 +294,15 @@ mod tests {
|
||||
|
||||
#[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
|
||||
@@ -345,8 +338,6 @@ mod tests {
|
||||
|
||||
#[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);
|
||||
@@ -359,13 +350,10 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -463,7 +463,7 @@ mod tests {
|
||||
use super::super::log::{group_streams, parse_jsonl_str};
|
||||
use super::*;
|
||||
|
||||
// ── JSONL fixture builders ─────────────────────────────────────────
|
||||
// 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).
|
||||
|
||||
@@ -498,7 +498,8 @@ mod tests {
|
||||
kind: &str,
|
||||
events: (u64, u64),
|
||||
accel_avg: (f32, Option<f64>),
|
||||
lines: (f32, i64, i64, i64), // desired, applied_total, flushed, backlog_after
|
||||
// desired, applied_total, flushed, backlog_after
|
||||
lines: (f32, i64, i64, i64),
|
||||
msf: Option<f64>,
|
||||
dropped: Option<i64>,
|
||||
) -> String {
|
||||
@@ -710,7 +711,8 @@ mod tests {
|
||||
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
|
||||
// speed echo ≠ expected 1.0
|
||||
fixture[0] = start(0.0, 0.0, "auto", 6.0);
|
||||
assert_violated(check(InvariantId::Cfg, &C1, &fixture), "speed");
|
||||
let expected_wheel = ExpectedProfile {
|
||||
mode: "wheel",
|
||||
|
||||
@@ -292,7 +292,8 @@ mod tests {
|
||||
|
||||
fn jsonl(lines: &[&str]) -> String {
|
||||
let mut out = lines.join("\n");
|
||||
out.push('\n'); // producer's writeln! always terminates lines
|
||||
// producer's writeln! always terminates lines
|
||||
out.push('\n');
|
||||
out
|
||||
}
|
||||
|
||||
|
||||
@@ -35,7 +35,6 @@ pub enum CellStatus {
|
||||
}
|
||||
|
||||
impl CellStatus {
|
||||
/// Fixed-width table label.
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
CellStatus::Pass => "PASS",
|
||||
@@ -46,7 +45,6 @@ impl CellStatus {
|
||||
}
|
||||
}
|
||||
|
||||
/// One invariant row of a [`CellReport`].
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct InvariantReport {
|
||||
/// Design vocabulary id (`I-ORD`, …).
|
||||
@@ -57,7 +55,6 @@ pub struct InvariantReport {
|
||||
pub detail: Option<String>,
|
||||
}
|
||||
|
||||
/// Verdict of one matrix cell run.
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct CellReport {
|
||||
pub cell_id: String,
|
||||
@@ -88,8 +85,7 @@ pub fn exit_code(reports: &[CellReport]) -> u8 {
|
||||
u8::from(failed)
|
||||
}
|
||||
|
||||
/// Write `report.json` (pretty, array of [`CellReport`]) into `dir`,
|
||||
/// creating it as needed; returns the file path.
|
||||
/// Write the reports as `dir/report.json`, creating `dir` as needed.
|
||||
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()))?;
|
||||
@@ -208,7 +204,6 @@ mod tests {
|
||||
"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");
|
||||
}
|
||||
|
||||
@@ -267,12 +262,10 @@ mod tests {
|
||||
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!(
|
||||
|
||||
@@ -68,7 +68,6 @@ const COMPLETION_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
/// 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",
|
||||
@@ -145,7 +144,6 @@ fn panic_message(err: tokio::task::JoinError) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything [`run_cell`] needs from a completed (non-aborted) cell body.
|
||||
struct CellRun {
|
||||
outcomes: Vec<(InvariantId, InvariantResult)>,
|
||||
streams: usize,
|
||||
@@ -311,11 +309,9 @@ fn check_screen(
|
||||
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`.
|
||||
@@ -370,8 +366,8 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The wire bytes of the shared wheel position, pinned to the
|
||||
/// `40;12` encoding documented on `WHEEL_ROW`/`WHEEL_COL`.
|
||||
/// Pins the wire bytes 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");
|
||||
@@ -382,9 +378,7 @@ mod tests {
|
||||
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);
|
||||
@@ -422,13 +416,11 @@ mod tests {
|
||||
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")),
|
||||
@@ -439,7 +431,7 @@ mod tests {
|
||||
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)…
|
||||
// One xfail row passing flips the cell to XPass: the fixed-bug tripwire.
|
||||
let half_fixed = [
|
||||
(SmoothCoast, InvariantResult::Pass),
|
||||
(NoDrop, violated("dropped 74")),
|
||||
@@ -448,7 +440,7 @@ mod tests {
|
||||
assert_eq!(status, CellStatus::XPass);
|
||||
assert!(rows[0].detail.as_deref().unwrap().contains("promote"));
|
||||
|
||||
// …but any non-xfail violation dominates everything.
|
||||
// A non-xfail violation dominates everything.
|
||||
let broken = [
|
||||
(Cap, violated("flushed 40 exceeds cap 25")),
|
||||
(SmoothCoast, InvariantResult::Pass),
|
||||
|
||||
@@ -67,7 +67,7 @@ const MARKER_PREFIX: &str = "MARKER-";
|
||||
/// 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
|
||||
/// 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}")
|
||||
|
||||
@@ -6,11 +6,10 @@
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
// Use `vte` re-exported from `alacritty_terminal` (via ptyctl) instead of
|
||||
// a separate direct dependency.
|
||||
// `vte` is re-exported by `alacritty_terminal` (pulled in via ptyctl), so no
|
||||
// direct dependency on it is needed.
|
||||
use alacritty_terminal::vte;
|
||||
|
||||
/// Timing data for a single rendered frame.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FrameTiming {
|
||||
/// Wall-clock duration from BeginSynchronizedUpdate to EndSynchronizedUpdate.
|
||||
@@ -19,11 +18,6 @@ pub struct FrameTiming {
|
||||
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,
|
||||
@@ -37,22 +31,20 @@ impl FrameTimingParser {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// Counts only frames whose end marker has been seen; a frame still open
|
||||
/// is not included.
|
||||
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;
|
||||
@@ -66,7 +58,6 @@ impl Default for FrameTimingParser {
|
||||
}
|
||||
}
|
||||
|
||||
/// Internal VTE `Perform` implementation that detects frame boundaries.
|
||||
struct FrameTimingHandler {
|
||||
frame_start: Option<Instant>,
|
||||
timings: Vec<FrameTiming>,
|
||||
@@ -91,7 +82,8 @@ impl vte::Perform for FrameTimingHandler {
|
||||
_ignore: bool,
|
||||
action: char,
|
||||
) {
|
||||
// Only interested in CSI ? <param> h/l (private mode set/reset).
|
||||
// Synchronized-update markers are private modes, so anything without
|
||||
// the `?` intermediate cannot be one.
|
||||
if intermediates != b"?" {
|
||||
return;
|
||||
}
|
||||
@@ -104,12 +96,10 @@ impl vte::Perform for FrameTimingHandler {
|
||||
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(),
|
||||
|
||||
Reference in New Issue
Block a user