grok-build-upstream-mirror/crates/codegen/xai-grok-pager/tests/pty_e2e/scroll.rs
grokkybara[bot] 3af4d5d398 Synced from monorepo
Synced from monorepo

Changes:
- Shell: accept target response id on rewind execute
- Shell: stamp response id on chat user message chunks
- Worktree: optional rebuild and stale git registration cleanup in auto-GC
- Worktree: kind-aware auto-GC TTLs and config knobs
- Worktree: macOS process CWD scan and Unix PID liveness for GC guards
- Worktree: automatic throttled GC on startup (Linux age-based; non-Linux dead-only)
- Pager: add `[ui].combine_queued_prompts` to batch queued follow-ups
- Shell: stop overwriting user skills
- Tools: read markdown in `skills/` directories untruncated
- `/usage` shows per-session token and dollar usage in the TUI
- Security: prompt on environment-dumping `ps` variants
- Security: always-safe `kubectl` no longer runs arbitrary kubeconfig credential plugins without permission
- Tools: make scheduler deletion durable
- Shell: add relocation storage primitives
- Shell: give side model calls their own conversation ids
- Fix five workflow-runtime bugs (budget, pause, cancel, reconnect)
- Security: peel `env -S` / `--split-string` operands in the Bash permission gate (managed deny/ask)
- Pager: expose doctor in the TUI
- Security: block unauthorized RCE via abused safe commands
- Pager idle watcher cue: "1 subagent still running" instead of "watching · 1 subagent"
- Security: block `rg --pre` arbitrary code execution in auto-mode
- Voice: diagnose silent-mic failures (macOS permission) and add doctor/terminal-setup Voice section
- App builder deployer: `allow_forking` and `show_built_with_grok`
- Pager: stop stacking duplicate "Worked for" markers on parked turns
- Shell: support `max` as a distinct reasoning effort tier
- Tools: serialize background `/loop` fires on the whole work unit
- Shell: add working-directory relocation state primitives
- Proto: `ClientToolResult` and `ChatConfig` client-side tools
- Shell: model providers
- Chat: select App Builder product on the Build path
- Shell: attach author identity to feedback when the deployment opts in
- Doctor: fix for SSH wrap setup
- Workflow authoring skills: create-workflow and import-claude-workflow docs
- Add read-only grok doctor
- Sandbox: apply Landlock without a controlling TTY
- Pager: recover image paste over grok wrap on headless remotes
- Pager: make actions screen-mode aware
- Shell: resume sessions when the working directory moves
- Pager: centralize terminal diagnostics
- Workspace: gate inline shell file access
- Pager: centralize terminal probes
- Pager: edit minimal prompts in an external editor
- Pager: standardize backgrounding on Ctrl+B
- Shell: recap rides the parent turn's prompt cache
- Tools: add scheduler lifecycle version clock

Source-Revision: 0f4d7c91b8b2b408333f6de1e8a76cb8eaa71899
2026-07-21 18:10:23 +00:00

321 lines
14 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Scroll-test support: timed wheel-burst drivers and viewport-marker
//! helpers. Scroll tests import via `use super::scroll::*;` alongside
//! `use super::common::*;`.
//!
//! Frame capture convention: use the harness's live parser — `reset_timing()`
//! before the interaction, `frame_timings()` / `frame_count()` after the
//! drain (the `renders_on_action.rs` pattern). Frame COUNT and per-frame
//! CHARS are byte-deterministic and CI-assertable; DURATIONS are not — they
//! are load-sensitive, and under the no-drain drivers below the burst's
//! chunks are parsed during the post-burst `update()`, so durations reflect
//! drain pacing rather than real frame timing.
use std::time::Duration;
use xai_grok_pager_pty_harness::PtyHarness;
pub(crate) use xai_grok_pager_pty_harness::{SGR_SCROLL_DOWN, SGR_SCROLL_UP};
use super::common::{
AgentTurnExpectation, ContentController, DEFAULT_COLS, DEFAULT_ROWS, MOCK_RESPONSE_SENTINEL,
PROMPT, WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT, locate_screen_text, pager_binary, sgr_mouse,
};
/// Wheel-report position, 0-based (row,col): inside the scrollback pane at
/// the default 50×120 PTY. Encodes to the same bytes as the raw constants in
/// `resize_preserves_scroll_position.rs` (wire `40;12`).
pub(crate) const WHEEL_ROW: u16 = 11;
pub(crate) const WHEEL_COL: u16 = 39;
/// Emit one SGR (DECSET 1006) wheel press report per entry of `btns`
/// ([`SGR_SCROLL_UP`] / [`SGR_SCROLL_DOWN`]; a mixed slice drives direction
/// reversals mid-stream) at 0-based (row,col) via [`sgr_mouse`], sleeping
/// `interval` host-side (`std::thread::sleep`) BETWEEN writes — never before
/// the first or after the last.
///
/// The pager classifies a scroll stream from inter-event ARRIVAL timing
/// (constants in pager `src/input/mouse.rs`), and the harness terminal
/// (`TERM=xterm-256color`, `TERM_PROGRAM` stripped → `TerminalName::Unknown`)
/// resolves to `events_per_tick = 3`. On such ept≥2 terminals a stream
/// promotes to Wheel only when the first 3 reports land within 12ms of the
/// stream's start (`DEFAULT_WHEEL_TICK_DETECT_MAX_MS`); anything slower never
/// promotes and finalizes as Trackpad once a >80ms `STREAM_GAP` closes the
/// stream. So under this harness: `Duration::ZERO` (back-to-back reports)
/// drives wheel clicks, while a long run of spaced reports — even 40ms apart —
/// is a trackpad stream. "Slow interval = wheel" holds only on ept=1 brands
/// (iTerm2 / WezTerm / VS Code embeds, via `trackpad_detect_max_interval_ms`
/// 3060ms), and 8ms is the acceleration fast band (`ACCEL_INTERVAL_FAST_MS`),
/// not a classifier threshold.
///
/// `interval` only LOWER-BOUNDS each gap: sleep plus scheduler jitter can
/// stretch any gap under CI load (a nominal 6ms past the 12ms promotion
/// window, say). Tests asserting classification-dependent behavior must
/// tolerate or detect stretched gaps; prefer assertions that hold under
/// either classification (scrolled at all, ≥1 frame, ≤1 frame per event).
/// Deliberately dumb: no draining, no assertions — callers `update()`
/// afterwards and assert on screen state / frame captures.
///
/// Harness mirror: the scroll-matrix runner's gesture-replay loop
/// (`xai-grok-pager-pty-harness/src/scroll_matrix/runner.rs`) ports this
/// send loop onto `WheelStep` tables — apply fixes there too.
pub(crate) fn send_wheel_sequence(
h: &mut PtyHarness,
btns: &[u16],
row: u16,
col: u16,
interval: Duration,
) {
for (i, &btn) in btns.iter().enumerate() {
if i > 0 && !interval.is_zero() {
std::thread::sleep(interval);
}
h.inject_keys(sgr_mouse(btn, row, col, 'M').as_bytes())
.expect("inject SGR wheel report");
}
}
/// [`send_wheel_sequence`] for the common case: `count` same-direction reports.
pub(crate) fn send_wheel_burst(
h: &mut PtyHarness,
btn: u16,
count: usize,
row: u16,
col: u16,
interval: Duration,
) {
let btns = vec![btn; count];
send_wheel_sequence(h, &btns, row, col, interval);
}
/// Prefix shared by [`marker_line`] and the top-down screen parse in
/// [`topmost_visible_marker`].
const MARKER_PREFIX: &str = "MARKER-";
/// Unique numbered marker line `n` (`MARKER-0042`). Zero-padded so every
/// marker is the same width and no marker is a substring of another within a
/// [`marker_response`] transcript.
pub(crate) fn marker_line(n: usize) -> String {
format!("{MARKER_PREFIX}{n:04}")
}
/// A response of `count` numbered marker lines inside a fenced code block, so
/// markdown renders exactly one row per marker with no soft-wrap reflow (same
/// trick as `tall_response` in `common.rs`). Feed to `content.set_response(..)`;
/// scroll tests then assert viewport movement via [`marker_screen_row`] /
/// [`topmost_visible_marker`] deltas instead of fragile absolute positions.
pub(crate) fn marker_response(sentinel: &str, count: usize) -> String {
let mut s = String::with_capacity(count * 16 + 64);
s.push_str("```\n");
s.push_str(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 it is off-screen.
pub(crate) fn marker_screen_row(h: &PtyHarness, marker: &str) -> Option<u16> {
locate_screen_text(&h.screen_contents(), marker).map(|(row, _)| row)
}
/// Index of the topmost marker on screen: a single top-down pass parsing the
/// first `MARKER-nnnn` occurrence (`None` when no marker is visible; malformed
/// hits are skipped). Scrolling UP strictly decreases it by the number of rows
/// scrolled — the primitive for "viewport moved by K rows / did not jump"
/// assertions.
pub(crate) fn topmost_visible_marker(h: &PtyHarness) -> Option<usize> {
h.screen_contents().lines().find_map(|line| {
let digits_at = line.find(MARKER_PREFIX)? + MARKER_PREFIX.len();
line.get(digits_at..digits_at + 4)?.parse().ok()
})
}
/// Spawn the pager over a `marker_count`-marker transcript and drive it to
/// the primed scroll-test state (the shared preamble of every wheel pacing
/// test, per the `drive_to_scrollback_with_turn` precedent): response fully
/// streamed, viewport bottom-pinned with the first marker off-screen-top,
/// scrollback focused via Tab (Esc would silently arm the rewind picker,
/// whose ~800ms expiry redraw lands a non-burst frame in the capture
/// window; wheel reports are position-routed regardless of focus), then
/// quiesced and `reset_timing()` applied — counted frames come only from
/// what the caller does next.
///
/// Returns the harness, the content controller (keep it alive: it owns the
/// mock server), and the topmost visible marker index as the movement
/// baseline. Panics with the screen contents on any setup violation.
///
/// Destructure the controller as `_content`, never `_` — a `_` binding drops
/// it immediately, killing the mock server mid-test and surfacing as a
/// confusing 60s stream timeout instead of an obvious failure.
///
/// Harness mirror: `spawn_settled_marker_session` in
/// `xai-grok-pager-pty-harness/src/scroll_matrix/session.rs` is a port of
/// this preamble — fixes must flow both ways.
pub(crate) async fn spawn_bottom_pinned_marker_scrollback(
marker_count: usize,
) -> (PtyHarness, ContentController, usize) {
spawn_bottom_pinned_marker_scrollback_with_env(marker_count, &[]).await
}
/// [`spawn_bottom_pinned_marker_scrollback`] with extra pager env vars
/// (e.g. `GROK_SCROLL_SPEED`) appended to the controller's env.
pub(crate) async fn spawn_bottom_pinned_marker_scrollback_with_env(
marker_count: usize,
extra_env: &[(&str, &str)],
) -> (PtyHarness, ContentController, usize) {
let content = ContentController::start().await.expect("start content");
content.set_response(marker_response(MOCK_RESPONSE_SENTINEL, marker_count));
let binary = pager_binary().expect("resolve pager binary");
// spawn_with_content minus the fixed env: content env + the caller's.
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, DEFAULT_ROWS, DEFAULT_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");
// 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));
// Setup guards: bottom-pinned after the stream, the first marker must be
// off-screen-top (the transcript overflows the viewport) and some later
// marker visible — otherwise there is nothing to scroll into view.
assert!(
marker_screen_row(&harness, &marker_line(0)).is_none(),
"setup: {} already visible → transcript not taller than the screen\nscreen:\n{}",
marker_line(0),
harness.screen_contents()
);
let top_before = topmost_visible_marker(&harness).unwrap_or_else(|| {
panic!(
"setup: no marker visible after the stream\nscreen:\n{}",
harness.screen_contents()
)
});
harness.inject_keys(b"\t").expect("focus scrollback (tab)");
harness.update(Duration::from_millis(500));
// Footer wait is best-effort, as in drive_to_scrollback_with_turn.
let _ = harness.wait_for_text("Space:prompt", Duration::from_secs(5));
// Quiesce, then live-parser watermark (renders_on_action.rs pattern).
harness.update(Duration::from_millis(300));
harness.reset_timing();
(harness, content, top_before)
}
/// End marker of a [`spawn_streaming_marker_turn`] stream: the final streamed
/// word. The spawn guards pin it off-screen while the tail is in flight;
/// bottom-pinned follow renders it once the viewport tracks the stream to
/// its end — the witness for both "stream still in flight" and "viewport
/// followed the live bottom" assertions.
pub(crate) const STREAM_END_SENTINEL: &str = "STREAMDONE";
/// Turn text for [`spawn_streaming_marker_turn`]: the whole fenced
/// `marker_count`-marker block rides the FIRST delta (the mock splits deltas
/// on single spaces and the block contains none), so scrollable content
/// exists immediately; the space-separated `tail_words` tail then streams
/// word-by-word, ending in [`STREAM_END_SENTINEL`].
fn streaming_marker_turn_text(marker_count: usize, tail_words: usize) -> String {
let mut s = marker_response(MOCK_RESPONSE_SENTINEL, marker_count);
for i in 0..tail_words {
s.push_str(&format!("TAIL-{i:04} "));
}
s.push_str(STREAM_END_SENTINEL);
s
}
/// Spawn the pager onto a turn that is STILL STREAMING and provably cannot
/// complete (the shared preamble of the mid-stream wheel tests): the marker
/// block arrives on the first delta, the tail keeps deltas in flight at
/// `chunk_delay` per SSE event, and a matched expectation prevents terminal
/// completion until released. `extra_env` is appended to the controller's
/// pager env. Returns the harness, content controller, blocked turn, and the
/// bottom-pinned topmost visible marker index; setup asserts that marker zero
/// is above the viewport and [`STREAM_END_SENTINEL`] has not arrived.
///
/// Destructure the controller into a live binding (`content` / `_content`),
/// never `_` — a `_` binding drops it immediately, killing the mock server
/// mid-test and surfacing as a confusing stream timeout.
///
/// Harness mirror: `spawn_streaming_marker_session` in
/// `xai-grok-pager-pty-harness/src/scroll_matrix/session.rs` is a port of
/// this preamble — fixes must flow both ways.
pub(crate) async fn spawn_streaming_marker_turn(
marker_count: usize,
tail_words: usize,
chunk_delay: Duration,
extra_env: &[(&str, &str)],
) -> (PtyHarness, ContentController, AgentTurnExpectation, usize) {
let content = ContentController::start().await.expect("start content");
content.set_chunk_delay(Some(chunk_delay));
let turn = content.expect_agent_turn_blocked(
"streaming marker turn",
streaming_marker_turn_text(marker_count, tail_words),
);
let binary = pager_binary().expect("resolve pager binary");
// spawn_with_content minus the fixed env: content env + the caller's.
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, DEFAULT_ROWS, DEFAULT_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");
// The last marker rides the first delta, so this waits only for the
// stream to START, not to finish — the tail is still streaming after.
harness
.wait_for_text(&marker_line(marker_count - 1), Duration::from_secs(30))
.expect("marker block streamed");
harness.update(Duration::from_millis(300));
// Setup guards, all taken while bottom-pinned in follow mode:
// the transcript overflows the viewport (something to scroll to)…
assert!(
marker_screen_row(&harness, &marker_line(0)).is_none(),
"setup: {} already visible → transcript not taller than the screen\nscreen:\n{}",
marker_line(0),
harness.screen_contents()
);
// …and the last chunk has NOT streamed yet (bottom-pinned follow shows
// the newest content, so a finished stream would render the sentinel).
assert!(
!harness.contains_text(STREAM_END_SENTINEL),
"setup: {STREAM_END_SENTINEL} already on screen — the paced tail finished \
before the test's wheel activity could overlap the stream\nscreen:\n{}",
harness.screen_contents()
);
let top_before = topmost_visible_marker(&harness).unwrap_or_else(|| {
panic!(
"setup: no marker visible mid-stream\nscreen:\n{}",
harness.screen_contents()
)
});
(harness, content, turn, top_before)
}