Publish harness and TUI open-source
initial sync from the monorepo
This commit is contained in:
commit
c68e39f604
2734 changed files with 1437016 additions and 0 deletions
|
|
@ -0,0 +1,71 @@
|
|||
// Per-test-case module for the `pty_e2e` integration test crate.
|
||||
#[allow(unused_imports)]
|
||||
use crate::common::*;
|
||||
|
||||
/// CLI `--minimal` / `--fullscreen` must not write `[ui] screen_mode` to
|
||||
/// config.toml. Mode flags are session-scoped; only a manual config.toml edit
|
||||
/// should make a mode sticky across plain `grok` launches.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore]
|
||||
async fn minimal_cli_screen_mode_does_not_persist() {
|
||||
let content = ContentController::start().await.expect("start content");
|
||||
content.set_response(format!("{} no-sticky payload.", turn_sentinel(1)));
|
||||
|
||||
// Sessions are keyed by cwd: both runs must share a stable project dir.
|
||||
let project = tempfile::tempdir().expect("create project dir");
|
||||
std::fs::create_dir_all(project.path().join(".git")).expect("create .git");
|
||||
|
||||
// First run: explicit `--minimal` — must open minimal but not write config.
|
||||
let mut first = spawn_minimal_in_dir(&content, DEFAULT_ROWS, DEFAULT_COLS, &[], project.path());
|
||||
wait_minimal_ready(&mut first);
|
||||
|
||||
// Give any fire-and-forget persist path time to have written if it still
|
||||
// existed, pumping the PTY so the pager never blocks on a full buffer.
|
||||
let config_path = content.home().join(".grok").join("config.toml");
|
||||
let deadline = Instant::now() + Duration::from_secs(3);
|
||||
while Instant::now() < deadline {
|
||||
first.update(Duration::from_millis(100));
|
||||
}
|
||||
let body = std::fs::read_to_string(&config_path).unwrap_or_default();
|
||||
assert!(
|
||||
!body.contains("screen_mode"),
|
||||
"--minimal must not persist [ui] screen_mode; config.toml:\n{body}"
|
||||
);
|
||||
quit_minimal(&mut first);
|
||||
|
||||
// Second run: NO mode flag. Without a manual config preference the plain
|
||||
// launch must open fullscreen (welcome screen), not minimal.
|
||||
let binary = pager_binary().expect("resolve pager binary");
|
||||
let mut second = PtyHarness::spawn_with_content_in_dir(
|
||||
&binary,
|
||||
DEFAULT_ROWS,
|
||||
DEFAULT_COLS,
|
||||
&content,
|
||||
&["--no-leader"],
|
||||
Some(project.path()),
|
||||
)
|
||||
.expect("spawn plain pager");
|
||||
second.set_respond_to_queries(true);
|
||||
|
||||
second
|
||||
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
|
||||
.unwrap_or_else(|e| {
|
||||
panic!(
|
||||
"plain grok should open fullscreen after --minimal (no sticky write): {e}\nscreen:\n{}",
|
||||
second.screen_contents()
|
||||
)
|
||||
});
|
||||
|
||||
assert!(
|
||||
!second.contains_text(MINIMAL_IDLE_SENTINEL),
|
||||
"plain launch must not be minimal without config screen_mode\nscreen:\n{}",
|
||||
second.screen_contents()
|
||||
);
|
||||
assert!(
|
||||
!second.contains_text("panicked"),
|
||||
"pager panicked\nscreen:\n{}",
|
||||
second.screen_contents()
|
||||
);
|
||||
|
||||
second.quit().expect("clean quit");
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
// Per-test-case module for the `pty_e2e` integration test crate.
|
||||
#[allow(unused_imports)]
|
||||
use crate::common::*;
|
||||
|
||||
/// Minimal mode's core contract: a finalized assistant block is printed once
|
||||
/// into the terminal's NATIVE scrollback (via `insert_before`), not redrawn in
|
||||
/// the pinned live region. We force the commit above the viewport by streaming a
|
||||
/// response taller than the screen, then assert the head line — which scrolls
|
||||
/// off the top into history — is readable via the harness scrollback helpers.
|
||||
/// (Short responses stay on the visible static band above the live region — the
|
||||
/// content-anchored live region keeps them on screen; only a response genuinely
|
||||
/// taller than the screen proves content reaches *scrollback* specifically.)
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore]
|
||||
async fn minimal_commits_response_to_scrollback() {
|
||||
let content = ContentController::start().await.expect("start content");
|
||||
// Sentinel on the FIRST rendered row; 80 code-block rows >> the 50-row
|
||||
// screen, so the head scrolls into native scrollback once the block commits.
|
||||
// (Prose lines would markdown-reflow into one short paragraph that fits on
|
||||
// screen — see `tall_response`.)
|
||||
content.set_response(tall_response(MOCK_RESPONSE_SENTINEL, 80));
|
||||
|
||||
let mut harness = spawn_minimal(&content);
|
||||
wait_minimal_ready(&mut harness);
|
||||
|
||||
harness
|
||||
.inject_keys(format!("{PROMPT}\r").as_bytes())
|
||||
.expect("submit prompt");
|
||||
|
||||
// The assistant block is the running turn — it only commits to native
|
||||
// scrollback once the turn ends. Poll until the head sentinel lands in
|
||||
// scrollback (above the pinned viewport), which proves it committed rather
|
||||
// than merely streaming in the live tail.
|
||||
let deadline = Instant::now() + Duration::from_secs(40);
|
||||
while Instant::now() < deadline && !harness.scrollback_text().contains(MOCK_RESPONSE_SENTINEL) {
|
||||
harness.update(Duration::from_millis(100));
|
||||
}
|
||||
assert!(
|
||||
harness.scrollback_text().contains(MOCK_RESPONSE_SENTINEL),
|
||||
"committed assistant block must reach native scrollback\nscrollback:\n{}\nscreen:\n{}",
|
||||
harness.scrollback_text(),
|
||||
harness.screen_contents(),
|
||||
);
|
||||
assert!(
|
||||
content.has_chat_completion(),
|
||||
"mock inference server never received a chat completion\nrequests: {:?}",
|
||||
content.requests()
|
||||
);
|
||||
assert!(
|
||||
!harness.contains_text("panicked"),
|
||||
"pager panicked\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
|
||||
quit_minimal(&mut harness);
|
||||
}
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
// Per-test-case module for the `pty_e2e` integration test crate.
|
||||
#[allow(unused_imports)]
|
||||
use crate::common::*;
|
||||
|
||||
/// Overlay host: growing the live viewport for an overlay must
|
||||
/// scroll committed rows up into native scrollback (the `set_viewport_height`
|
||||
/// grow fix) rather than clobbering them, and shrinking it back when the overlay
|
||||
/// closes must leave them intact. Commit a tall response into scrollback, open
|
||||
/// the slash dropdown (grows the viewport over committed rows), close it
|
||||
/// (shrinks + re-anchors), and assert the committed head survived in scrollback.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore]
|
||||
async fn minimal_committed_content_survives_overlay_grow() {
|
||||
let content = ContentController::start().await.expect("start content");
|
||||
// Sentinel on the first rendered row; 80 code-block rows overflow the screen
|
||||
// so the head reaches native scrollback (prose would reflow to fit on screen
|
||||
// and never scroll — see `tall_response`).
|
||||
content.set_response(tall_response(MOCK_RESPONSE_SENTINEL, 80));
|
||||
|
||||
let mut harness = spawn_minimal(&content);
|
||||
wait_minimal_ready(&mut harness);
|
||||
|
||||
harness
|
||||
.inject_keys(format!("{PROMPT}\r").as_bytes())
|
||||
.expect("submit prompt");
|
||||
|
||||
// Precondition: the committed head must be in native scrollback first.
|
||||
let deadline = Instant::now() + Duration::from_secs(40);
|
||||
while Instant::now() < deadline && !harness.scrollback_text().contains(MOCK_RESPONSE_SENTINEL) {
|
||||
harness.update(Duration::from_millis(100));
|
||||
}
|
||||
assert!(
|
||||
harness.scrollback_text().contains(MOCK_RESPONSE_SENTINEL),
|
||||
"precondition: committed block must reach scrollback before the overlay\nscrollback:\n{}",
|
||||
harness.scrollback_text()
|
||||
);
|
||||
|
||||
// Open the slash dropdown → grows the live viewport over committed rows.
|
||||
inject_keys_paced(&mut harness, b"/mod");
|
||||
let dropdown_deadline = Instant::now() + Duration::from_secs(10);
|
||||
while Instant::now() < dropdown_deadline
|
||||
&& !harness
|
||||
.screen_contents()
|
||||
.contains("Switch the active model")
|
||||
{
|
||||
harness.update(Duration::from_millis(100));
|
||||
}
|
||||
assert!(
|
||||
harness
|
||||
.screen_contents()
|
||||
.contains("Switch the active model"),
|
||||
"slash dropdown must grow the viewport and render its items even when \
|
||||
committed content fills the screen\nscreen:\n{}\nscrollback:\n{}",
|
||||
harness.screen_contents(),
|
||||
harness.scrollback_text(),
|
||||
);
|
||||
// Close it → shrinks + re-anchors to the bottom.
|
||||
harness.inject_keys(keys::ESC).expect("close dropdown");
|
||||
harness.update(Duration::from_millis(400));
|
||||
|
||||
// The committed head must still be readable in scrollback: the grow/shrink
|
||||
// cycle must neither clobber nor lose it.
|
||||
assert!(
|
||||
harness.scrollback_text().contains(MOCK_RESPONSE_SENTINEL),
|
||||
"committed block must survive the overlay grow/shrink cycle\nscrollback:\n{}",
|
||||
harness.scrollback_text()
|
||||
);
|
||||
assert!(
|
||||
!harness.contains_text("panicked"),
|
||||
"pager panicked\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
|
||||
quit_minimal(&mut harness);
|
||||
}
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
// Per-test-case module for the `pty_e2e` integration test crate.
|
||||
#[allow(unused_imports)]
|
||||
use crate::common::*;
|
||||
|
||||
/// Resuming a minimal session with `--continue` reprints
|
||||
/// the prior transcript into native scrollback. Minimal has no separate history
|
||||
/// pane (the terminal owns history), so a resumed session would otherwise look
|
||||
/// empty — rather than a compact resume marker, minimal reprints the full
|
||||
/// transcript instead. This asserts the prior
|
||||
/// turn's content reappears after resume and a follow-up turn still works.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore]
|
||||
async fn minimal_continue_reprints_transcript() {
|
||||
let content = ContentController::start().await.expect("start content");
|
||||
content.set_response(format!("{} first session payload.", turn_sentinel(1)));
|
||||
|
||||
// Sessions are keyed by cwd: both runs must share a stable project dir.
|
||||
let project = tempfile::tempdir().expect("create project dir");
|
||||
std::fs::create_dir_all(project.path().join(".git")).expect("create .git");
|
||||
|
||||
let mut first = spawn_minimal_in_dir(&content, DEFAULT_ROWS, DEFAULT_COLS, &[], project.path());
|
||||
wait_minimal_ready(&mut first);
|
||||
first
|
||||
.inject_keys(format!("{PROMPT}\r").as_bytes())
|
||||
.expect("submit turn 1");
|
||||
first
|
||||
.wait_for_full_text(&turn_sentinel(1), Duration::from_secs(30))
|
||||
.expect("turn 1 committed to scrollback");
|
||||
quit_minimal(&mut first);
|
||||
|
||||
// Resume the same session. The transcript is reprinted into native
|
||||
// scrollback (no separate history pane in minimal).
|
||||
let mut resumed = spawn_minimal_in_dir(
|
||||
&content,
|
||||
DEFAULT_ROWS,
|
||||
DEFAULT_COLS,
|
||||
&["--continue"],
|
||||
project.path(),
|
||||
);
|
||||
resumed
|
||||
.wait_for_full_text(&turn_sentinel(1), WELCOME_TIMEOUT)
|
||||
.unwrap_or_else(|e| {
|
||||
panic!(
|
||||
"history must be reprinted after --continue: {e}\nfull:\n{}",
|
||||
resumed.full_text()
|
||||
)
|
||||
});
|
||||
|
||||
// A follow-up turn still works in the resumed session.
|
||||
content.set_response(format!("{} resumed payload.", turn_sentinel(2)));
|
||||
resumed
|
||||
.inject_keys(b"again\r")
|
||||
.expect("submit turn 2 after resume");
|
||||
resumed
|
||||
.wait_for_full_text(&turn_sentinel(2), Duration::from_secs(30))
|
||||
.expect("turn 2 rendered in resumed session");
|
||||
|
||||
assert!(
|
||||
!resumed.contains_text("panicked"),
|
||||
"pager panicked\nscreen:\n{}",
|
||||
resumed.screen_contents()
|
||||
);
|
||||
|
||||
quit_minimal(&mut resumed);
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
// Per-test-case module for the `pty_e2e` integration test crate.
|
||||
#[allow(unused_imports)]
|
||||
use crate::common::*;
|
||||
|
||||
/// Minimal mode has no shortcuts bar, so the double-press quit confirmation must
|
||||
/// surface under the prompt instead: a first Ctrl+C on an empty, idle prompt
|
||||
/// arms the quit confirmation and shows "press Ctrl+c again to quit", and a
|
||||
/// second Ctrl+C within the window exits. (Ctrl+Q / Ctrl+D arm the same way; the
|
||||
/// hint is rendered by `minimal::live::render_exit_hint`.)
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore]
|
||||
async fn minimal_ctrl_c_arms_and_quits() {
|
||||
let content = ContentController::start().await.expect("start content");
|
||||
let mut harness = spawn_minimal(&content);
|
||||
wait_minimal_ready(&mut harness);
|
||||
|
||||
// First Ctrl+C (CSI legacy ETX). Arms quit + shows the hint under the prompt.
|
||||
harness.inject_keys(b"\x03").expect("inject Ctrl+C");
|
||||
harness
|
||||
.wait_for_text("again to quit", Duration::from_secs(5))
|
||||
.unwrap_or_else(|e| {
|
||||
panic!(
|
||||
"quit-confirmation hint expected after first Ctrl+C: {e}\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
)
|
||||
});
|
||||
|
||||
// Second Ctrl+C within the confirm window exits the process.
|
||||
harness.inject_keys(b"\x03").expect("inject Ctrl+C again");
|
||||
let code = harness.wait_exit_code(Duration::from_secs(5));
|
||||
assert!(
|
||||
code.is_some(),
|
||||
"second Ctrl+C should quit minimal\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
// Per-test-case module for the `pty_e2e` integration test crate.
|
||||
#[allow(unused_imports)]
|
||||
use crate::common::*;
|
||||
|
||||
/// Minimal + Apple Terminal: Ctrl+O is the send-now chord. With an empty
|
||||
/// composer and a mid-turn queued follow-up it must send that row now —
|
||||
/// cancel-and-send: turn 1 is cancelled silently and the row runs as its own
|
||||
/// next turn (no interjection preamble) — not open the transcript pager remap.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore]
|
||||
async fn minimal_ctrl_o_send_now_queued_apple_terminal() {
|
||||
let content = ContentController::start().await.expect("start content");
|
||||
content.set_turns([
|
||||
slow_turn_text("STEPONE"),
|
||||
"STEPTWO send-now via Ctrl+O acknowledged.".to_owned(),
|
||||
]);
|
||||
// Hold turn 1 open deterministically: its content streams, but its
|
||||
// completion is gated until we release it below. Chunk-delay pacing alone
|
||||
// left a wall-clock race — under parallel-suite load turn 1 could finish
|
||||
// before Ctrl+O landed, so the follow-up was promoted FIFO as a plain
|
||||
// prompt and the send-now chrome never appeared.
|
||||
content.hold_agent_completions();
|
||||
|
||||
let binary = pager_binary().expect("resolve pager binary");
|
||||
let mut env = content.env_for_pager();
|
||||
env.push(("TERM_PROGRAM".into(), "Apple_Terminal".into()));
|
||||
// Non-interactive $PAGER so a mistaken transcript open fails fast rather
|
||||
// than hanging in `less` if the predicate regresses.
|
||||
env.push(("PAGER".into(), "cat".into()));
|
||||
let env_refs: Vec<(&str, &str)> = env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
|
||||
let mut harness = PtyHarness::new(&binary, DEFAULT_ROWS, DEFAULT_COLS, MINIMAL_ARGS, &env_refs)
|
||||
.expect("spawn minimal + Apple_Terminal");
|
||||
harness.set_respond_to_queries(true);
|
||||
|
||||
wait_minimal_ready(&mut harness);
|
||||
|
||||
harness
|
||||
.inject_keys(format!("{PROMPT}\r").as_bytes())
|
||||
.expect("submit prompt");
|
||||
harness
|
||||
.wait_for_text("STEPONE", Duration::from_secs(30))
|
||||
.expect("turn 1 streaming");
|
||||
|
||||
harness
|
||||
.inject_keys(b"minimal send-now payload\r")
|
||||
.expect("queue follow-up");
|
||||
harness
|
||||
.wait_for_text("1 queued", Duration::from_secs(10))
|
||||
.expect("queue indicator");
|
||||
|
||||
// Empty composer + queue: Ctrl+O must yield to send-now, not transcript.
|
||||
// Cancel-and-send: the shell silently cancels turn 1 (its held completion
|
||||
// is irrelevant — the abort wins) and the row commits as a standard "❯ "
|
||||
// prompt block for its own turn. Turn 1 is still gated open here, so the
|
||||
// queued row cannot have promoted FIFO.
|
||||
harness.inject_keys(CTRL_O).expect("Ctrl+O send-now");
|
||||
// Generous deadline: with turn 1 gated open there is no promotion race
|
||||
// left to mask — this wait is pure render latency, which under heavy
|
||||
// parallel-suite load can exceed the old 15s budget.
|
||||
harness
|
||||
.wait_for_text("\u{276F} minimal send-now payload", Duration::from_secs(60))
|
||||
.expect("send-now chrome (not a silent transcript open)");
|
||||
|
||||
// Let the mock's gate go so the promoted turn streams its reply.
|
||||
content.release_agent_completions();
|
||||
harness
|
||||
.wait_for_text("STEPTWO", Duration::from_secs(40))
|
||||
.expect("send-now turn reply");
|
||||
|
||||
// The send-now cancel of turn 1 is silent (scrollback-aware check:
|
||||
// minimal commits blocks into native history).
|
||||
assert!(
|
||||
!harness.contains_full_text("Turn cancelled by user"),
|
||||
"send-now cancel must not render a cancelled marker\nfull contents:\n{}",
|
||||
harness.full_text()
|
||||
);
|
||||
|
||||
let users = all_user_message_blobs(&content);
|
||||
let sent = users
|
||||
.iter()
|
||||
.find(|u| u.contains("minimal send-now payload"))
|
||||
.unwrap_or_else(|| panic!("queued follow-up never on wire: {users:#?}"));
|
||||
assert!(
|
||||
!sent.contains(INTERJECTION_WIRE_PREFIX),
|
||||
"send-now must not use the interjection preamble: {sent}"
|
||||
);
|
||||
assert!(
|
||||
sent.contains("<user_query>"),
|
||||
"send-now must arrive as a standard user_query prompt: {sent}"
|
||||
);
|
||||
|
||||
assert!(
|
||||
!harness.contains_text("panicked"),
|
||||
"pager panicked\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
quit_minimal(&mut harness);
|
||||
}
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
// Per-test-case module for the `pty_e2e` integration test crate.
|
||||
#[allow(unused_imports)]
|
||||
use crate::common::*;
|
||||
|
||||
/// Minimal mode guards the documented `in_flight_committed` dogfood
|
||||
/// double-show: a promoted queued prompt's "❯ " block commits (prints) into
|
||||
/// native scrollback immediately, so cancelling its turn pre-first-token
|
||||
/// (minimal's cancel gesture is Ctrl+C; Esc is swallowed) must SKIP the
|
||||
/// composer rewind — a rewind would leave the printed block on screen AND
|
||||
/// refill the composer, showing the prompt twice. Standard cancel instead:
|
||||
/// the block renders exactly once and the cancel marker is visible.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore]
|
||||
async fn minimal_double_esc_committed_queued_prompt_single_render() {
|
||||
const QUEUED_PROMPT: &str = "bravo promoted block";
|
||||
|
||||
let content = ContentController::start().await.expect("start content");
|
||||
// Gate turn 1's completion so the queue provably lands mid-turn; turn 2
|
||||
// (the promoted prompt's) streams nothing before the cancel thanks to
|
||||
// the pacing set just before the release.
|
||||
content.hold_agent_completions();
|
||||
content.set_turns([
|
||||
"STEPONE first reply.".to_owned(),
|
||||
"STEPTWO never streams before the cancel.".to_owned(),
|
||||
]);
|
||||
|
||||
let mut harness = spawn_minimal(&content);
|
||||
wait_minimal_ready(&mut harness);
|
||||
|
||||
harness
|
||||
.inject_keys(format!("{PROMPT}\r").as_bytes())
|
||||
.expect("submit prompt");
|
||||
harness
|
||||
.wait_for_text("STEPONE", Duration::from_secs(30))
|
||||
.expect("turn 1 streamed (completion still gated)");
|
||||
|
||||
harness
|
||||
.inject_keys(format!("{QUEUED_PROMPT}\r").as_bytes())
|
||||
.expect("queue follow-up mid-turn");
|
||||
harness
|
||||
.wait_for_text("1 queued", Duration::from_secs(10))
|
||||
.expect("queue indicator");
|
||||
|
||||
// Promote: turn 1 ends, the queued prompt's block commits (prints) and
|
||||
// its turn starts — but its first token is 30s away, the exact window
|
||||
// where a naive rewind would double-show the committed block.
|
||||
content.set_chunk_delay(Some(Duration::from_secs(30)));
|
||||
content.release_agent_completions();
|
||||
harness
|
||||
.wait_for_full_text(
|
||||
&format!("\u{276F} {QUEUED_PROMPT}"),
|
||||
Duration::from_secs(30),
|
||||
)
|
||||
.expect("promoted prompt block committed");
|
||||
let committed = harness.full_text().matches(QUEUED_PROMPT).count();
|
||||
assert_eq!(
|
||||
committed,
|
||||
1,
|
||||
"committed block must print once\nfull contents:\n{}",
|
||||
harness.full_text()
|
||||
);
|
||||
|
||||
// Cancel the promoted turn pre-first-token. The committed block forces
|
||||
// the standard cancel path (rewind skipped): marker renders, composer
|
||||
// stays empty, and the prompt count does NOT grow.
|
||||
harness.inject_keys(keys::CTRL_C).expect("Ctrl+C cancel");
|
||||
harness
|
||||
.wait_for_full_text("Turn cancelled by user", Duration::from_secs(15))
|
||||
.expect("standard cancel marker (not a silent rewind)");
|
||||
|
||||
harness.update(Duration::from_millis(500));
|
||||
assert_eq!(
|
||||
harness.full_text().matches(QUEUED_PROMPT).count(),
|
||||
committed,
|
||||
"cancel must not re-show the committed prompt (composer refill = double render)\n\
|
||||
full contents:\n{}",
|
||||
harness.full_text()
|
||||
);
|
||||
|
||||
assert!(
|
||||
!harness.contains_text("panicked"),
|
||||
"pager panicked\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
quit_minimal(&mut harness);
|
||||
}
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
// Per-test-case module for the `pty_e2e` integration test crate.
|
||||
#[allow(unused_imports)]
|
||||
use crate::common::*;
|
||||
|
||||
/// Mid-turn Esc in minimal mode is a swallowed no-op (the prompt is always
|
||||
/// focused). Esc must NOT cancel; cancel remains on Ctrl+C.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore]
|
||||
async fn minimal_esc_mid_turn_is_swallowed() {
|
||||
let content = ContentController::start().await.expect("start content");
|
||||
// Paced, long stream so the turn is provably still running when Esc lands.
|
||||
let long = format!(
|
||||
"{MOCK_RESPONSE_SENTINEL} {}",
|
||||
"streaming filler words for the cancellation window. ".repeat(120)
|
||||
);
|
||||
content.set_response(long);
|
||||
content.set_chunk_delay(Some(Duration::from_millis(50)));
|
||||
|
||||
let mut harness = spawn_minimal(&content);
|
||||
wait_minimal_ready(&mut harness);
|
||||
|
||||
harness
|
||||
.inject_keys(format!("{PROMPT}\r").as_bytes())
|
||||
.expect("submit prompt");
|
||||
harness
|
||||
.wait_for_text(MOCK_RESPONSE_SENTINEL, Duration::from_secs(30))
|
||||
.expect("turn streaming in the live tail");
|
||||
|
||||
harness.inject_keys(keys::ESC).expect("press esc");
|
||||
harness.update(Duration::from_millis(1000));
|
||||
|
||||
// Full-text: minimal commits the cancel marker to native scrollback, so it
|
||||
// may sit above the pinned viewport — check scrollback + screen.
|
||||
assert!(
|
||||
!harness.contains_full_text("Turn cancelled by user"),
|
||||
"mid-turn Esc must NOT cancel in minimal mode\nfull contents:\n{}",
|
||||
harness.full_text()
|
||||
);
|
||||
|
||||
// Positive tail: prove the turn was still alive at Esc-time (the negative
|
||||
// check above would false-pass on an already-finished turn) and that
|
||||
// Ctrl+C — the replacement cancel gesture — works in minimal mode. The
|
||||
// prompt is empty and the turn is running, so Ctrl+C cancels (the minimal
|
||||
// quit arm applies only to an idle empty prompt).
|
||||
harness.inject_keys(keys::CTRL_C).expect("press ctrl+c");
|
||||
harness
|
||||
.wait_for_full_text("Turn cancelled by user", Duration::from_secs(15))
|
||||
.expect("Ctrl+C must cancel the still-running turn in minimal mode");
|
||||
|
||||
assert!(
|
||||
!harness.contains_text("panicked"),
|
||||
"pager panicked\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
|
||||
quit_minimal(&mut harness);
|
||||
}
|
||||
|
|
@ -0,0 +1,153 @@
|
|||
// Per-test-case module for the `pty_e2e` integration test crate.
|
||||
#[allow(unused_imports)]
|
||||
use crate::common::*;
|
||||
|
||||
/// Minimal mode paints the welcome card edge-to-edge (no outer horizontal pad).
|
||||
/// The live region's status / prompt / info rows and committed user+agent blocks
|
||||
/// must share that left edge — previously they sat at `block_pad_left + accent`
|
||||
/// (= 3 columns of blank gutter), which looked misaligned against the welcome
|
||||
/// box. Assert every non-blank visible row either is welcome-card chrome
|
||||
/// (border / logo interior) or starts at column 0.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore]
|
||||
async fn minimal_flush_left_no_hpad() {
|
||||
let content = ContentController::start().await.expect("start content");
|
||||
content.set_response(format!(
|
||||
"{MOCK_RESPONSE_SENTINEL} — flush-left alignment check."
|
||||
));
|
||||
|
||||
let mut harness = spawn_minimal(&content);
|
||||
wait_minimal_ready(&mut harness);
|
||||
|
||||
// Idle: status + prompt + info bar must be flush-left (no leading spaces).
|
||||
assert_flush_left_live_rows(
|
||||
&harness.screen_contents(),
|
||||
&["minimal · /help"],
|
||||
"idle live region",
|
||||
);
|
||||
|
||||
harness
|
||||
.inject_keys(format!("{PROMPT}\r").as_bytes())
|
||||
.expect("submit prompt");
|
||||
harness
|
||||
.wait_for_text(MOCK_RESPONSE_SENTINEL, Duration::from_secs(30))
|
||||
.expect("response renders");
|
||||
// Let the turn finish and commit settle into scrollback / live region.
|
||||
harness
|
||||
.wait_for_text(MINIMAL_IDLE_SENTINEL, Duration::from_secs(20))
|
||||
.expect("return to idle after response");
|
||||
harness.update(Duration::from_millis(200));
|
||||
|
||||
let screen = harness.screen_contents();
|
||||
assert!(
|
||||
screen.contains(MOCK_RESPONSE_SENTINEL),
|
||||
"response must be on screen\nscreen:\n{screen}"
|
||||
);
|
||||
// Committed user line (`❯ go`), agent response, and live chrome all
|
||||
// flush-left with the welcome card.
|
||||
assert_flush_left_live_rows(
|
||||
&screen,
|
||||
&[
|
||||
&format!("❯ {PROMPT}"),
|
||||
MOCK_RESPONSE_SENTINEL,
|
||||
"minimal · /help",
|
||||
],
|
||||
"after response",
|
||||
);
|
||||
|
||||
// Slash menu: typing a command prefix opens the dropdown below the prompt.
|
||||
// Its rows must be flush-left too — previously the panel sat at the layout
|
||||
// hpad and the item rows one further column in (`❯ /transcript` at col 3).
|
||||
harness
|
||||
.inject_keys(b"/tra")
|
||||
.expect("type slash command prefix");
|
||||
harness
|
||||
.wait_for_text(
|
||||
"View the full conversation transcript",
|
||||
Duration::from_secs(10),
|
||||
)
|
||||
.expect("slash dropdown opens");
|
||||
assert_flush_left_live_rows(
|
||||
&harness.screen_contents(),
|
||||
&["View the full conversation transcript"],
|
||||
"slash dropdown",
|
||||
);
|
||||
// Close the dropdown / clear the prompt so quit isn't intercepted.
|
||||
harness.inject_keys(b"\x1b").expect("esc closes dropdown");
|
||||
harness.update(Duration::from_millis(100));
|
||||
|
||||
// Permission modal: a scripted `run_terminal_command` tool call (no --yolo)
|
||||
// opens the prompt-replacing permission modal. Its rows must be flush-left
|
||||
// too — previously the whole modal sat at the layout hpad (2 columns in).
|
||||
// The accent `┃` paints the modal's first column, so a correct row has zero
|
||||
// leading spaces.
|
||||
content.set_response("PERMISSION_SETTLED — turn finished after the allow.");
|
||||
let args = json!({
|
||||
"command": "touch flush_marker.txt",
|
||||
"description": "flush-left permission check",
|
||||
})
|
||||
.to_string();
|
||||
content.enqueue_response(
|
||||
"/v1/responses",
|
||||
ScriptedResponse::sse(responses_api_tool_call_events(
|
||||
"call_flush",
|
||||
"run_terminal_command",
|
||||
&args,
|
||||
)),
|
||||
);
|
||||
content.enqueue_response(
|
||||
"/v1/chat/completions",
|
||||
ScriptedResponse::sse(chat_completions_tool_call_events(
|
||||
"run_terminal_command",
|
||||
&args,
|
||||
)),
|
||||
);
|
||||
harness
|
||||
.inject_keys(b"run the flush check\r")
|
||||
.expect("submit tool prompt");
|
||||
harness
|
||||
.wait_for_text("No, reject", Duration::from_secs(30))
|
||||
.expect("permission modal opens");
|
||||
assert_flush_left_live_rows(
|
||||
&harness.screen_contents(),
|
||||
&["No, reject"],
|
||||
"permission modal",
|
||||
);
|
||||
// Allow once (shortcut `1`) so the turn settles, then wait for idle.
|
||||
harness.inject_keys(b"1").expect("allow once");
|
||||
harness
|
||||
.wait_for_text("PERMISSION_SETTLED", Duration::from_secs(30))
|
||||
.expect("turn settles after allow");
|
||||
harness
|
||||
.wait_for_text(MINIMAL_IDLE_SENTINEL, Duration::from_secs(20))
|
||||
.expect("return to idle after permission turn");
|
||||
|
||||
assert!(
|
||||
!harness.contains_text("panicked"),
|
||||
"pager panicked\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
|
||||
quit_minimal(&mut harness);
|
||||
}
|
||||
|
||||
/// For each `needle`, find the first screen line containing it and assert that
|
||||
/// line has no leading ASCII spaces (flush-left). Welcome-card rows start with
|
||||
/// box-drawing chars at col 0 already; this targets the previously-padded
|
||||
/// live/committed content.
|
||||
fn assert_flush_left_live_rows(screen: &str, needles: &[&str], phase: &str) {
|
||||
for needle in needles {
|
||||
let line = screen
|
||||
.lines()
|
||||
.find(|l| l.contains(needle))
|
||||
.unwrap_or_else(|| {
|
||||
panic!("{phase}: no screen line contains {needle:?}\nscreen:\n{screen}")
|
||||
});
|
||||
let lead = line.len() - line.trim_start_matches(' ').len();
|
||||
assert_eq!(
|
||||
lead, 0,
|
||||
"{phase}: line containing {needle:?} must be flush-left \
|
||||
(no leading spaces), got lead={lead}: {line:?}\nscreen:\n{screen}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
// Per-test-case module for the `pty_e2e` integration test crate.
|
||||
#[allow(unused_imports)]
|
||||
use crate::common::*;
|
||||
|
||||
/// `/help` opens the command palette inline in minimal mode, hosted in
|
||||
/// the grown live viewport by the generalized app-modal host (the same host
|
||||
/// that renders settings) — proving minimal now renders the full `ActiveModal`
|
||||
/// family, not just settings. Esc dismisses it back to the prompt.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore]
|
||||
async fn minimal_help_opens_command_palette() {
|
||||
let content = ContentController::start().await.expect("start content");
|
||||
let mut harness = spawn_minimal(&content);
|
||||
wait_minimal_ready(&mut harness);
|
||||
|
||||
inject_keys_paced(&mut harness, b"/help");
|
||||
harness.inject_keys(b"\r").expect("submit /help");
|
||||
|
||||
// "New Session" is a stable command-palette entry that renders only inside
|
||||
// the palette modal (not the status line or the slash dropdown).
|
||||
harness
|
||||
.wait_for_text("New Session", Duration::from_secs(10))
|
||||
.expect("command palette opens inline");
|
||||
|
||||
// Esc closes it. The palette opens in input mode, so the first Esc may exit
|
||||
// input mode and a second closes — press up to twice.
|
||||
for _ in 0..2 {
|
||||
harness.inject_keys(keys::ESC).expect("press esc");
|
||||
harness.update(Duration::from_millis(300));
|
||||
if !harness.contains_text("New Session") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
harness
|
||||
.wait_for_text(MINIMAL_IDLE_SENTINEL, Duration::from_secs(10))
|
||||
.expect("palette closed, back to the prompt");
|
||||
assert!(
|
||||
!harness.contains_text("New Session"),
|
||||
"command palette must be gone after Esc\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
assert!(
|
||||
!harness.contains_text("panicked"),
|
||||
"pager panicked\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
|
||||
quit_minimal(&mut harness);
|
||||
}
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
// Per-test-case module for the `pty_e2e` integration test crate.
|
||||
#[allow(unused_imports)]
|
||||
use crate::common::*;
|
||||
|
||||
/// `/new` in minimal mode starts a fresh session — it commits a
|
||||
/// second welcome card and resets the committed frontier — while leaving the
|
||||
/// prior turn's committed lines in the terminal's native scrollback (we cannot,
|
||||
/// and must not, un-print them). After `/new` a fresh turn streams normally.
|
||||
///
|
||||
/// The first turn is genuinely taller than the screen (a fenced code block, not
|
||||
/// reflowable prose) so the first welcome card and the turn's head scroll into
|
||||
/// *native scrollback* before `/new`. The robust "new session" signal is then
|
||||
/// that a *second* welcome card appears (two `Grok Build` banners in
|
||||
/// scrollback+screen), independent of exactly how content scrolled.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore]
|
||||
async fn minimal_new_session_keeps_history_and_resets() {
|
||||
/// Substring printed once per minimal welcome card (see `minimal::welcome`).
|
||||
const WELCOME_BANNER: &str = "Grok Build";
|
||||
|
||||
let content = ContentController::start().await.expect("start content");
|
||||
// Code-block rows (not prose, which markdown-reflows to fit on screen) so
|
||||
// turn 1 is genuinely taller than the screen and its head commits into
|
||||
// native scrollback before `/new` — see `tall_response`.
|
||||
content.set_response(tall_response(&turn_sentinel(1), 80));
|
||||
|
||||
let mut harness = spawn_minimal(&content);
|
||||
wait_minimal_ready(&mut harness);
|
||||
harness
|
||||
.inject_keys(format!("{PROMPT}\r").as_bytes())
|
||||
.expect("submit turn 1");
|
||||
|
||||
// Wait until turn 1's head has committed into *native scrollback* (above the
|
||||
// viewport). The first welcome card was printed before it, so it is in
|
||||
// scrollback too by this point.
|
||||
let deadline = Instant::now() + Duration::from_secs(40);
|
||||
while Instant::now() < deadline && !harness.scrollback_text().contains(&turn_sentinel(1)) {
|
||||
harness.update(Duration::from_millis(100));
|
||||
}
|
||||
assert!(
|
||||
harness.scrollback_text().contains(&turn_sentinel(1)),
|
||||
"turn 1 must reach native scrollback before /new\nscrollback:\n{}",
|
||||
harness.scrollback_text()
|
||||
);
|
||||
|
||||
// `/new` → fresh session: commits a second welcome card and resets the frontier.
|
||||
inject_keys_paced(&mut harness, b"/new");
|
||||
harness.inject_keys(b"\r").expect("submit /new");
|
||||
|
||||
// The "new session" signal: a *second* welcome banner now exists across
|
||||
// scrollback + screen (the first is preserved in native scrollback).
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
while Instant::now() < deadline && harness.full_text().matches(WELCOME_BANNER).count() < 2 {
|
||||
harness.update(Duration::from_millis(100));
|
||||
}
|
||||
assert!(
|
||||
harness.full_text().matches(WELCOME_BANNER).count() >= 2,
|
||||
"/new must commit a second welcome card (first preserved in scrollback)\nfull:\n{}",
|
||||
harness.full_text()
|
||||
);
|
||||
|
||||
// Prior turn's committed lines remain in native scrollback (not wiped).
|
||||
assert!(
|
||||
harness.contains_full_text(&turn_sentinel(1)),
|
||||
"prior turn must remain in native scrollback after /new\nfull:\n{}",
|
||||
harness.full_text()
|
||||
);
|
||||
|
||||
// A fresh turn streams in the new session.
|
||||
content.set_response(format!("{} new session payload.", turn_sentinel(2)));
|
||||
harness
|
||||
.inject_keys(b"hi\r")
|
||||
.expect("submit a turn in the new session");
|
||||
harness
|
||||
.wait_for_full_text(&turn_sentinel(2), Duration::from_secs(30))
|
||||
.expect("new-session turn streams");
|
||||
|
||||
assert!(
|
||||
!harness.contains_text("panicked"),
|
||||
"pager panicked\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
|
||||
quit_minimal(&mut harness);
|
||||
}
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
// Per-test-case module for the `pty_e2e` integration test crate.
|
||||
#[allow(unused_imports)]
|
||||
use crate::common::*;
|
||||
|
||||
/// Minimal status line: pressing Enter with a draft while a turn is
|
||||
/// running queues the prompt, and the status line reports the pending count
|
||||
/// ("N queued") together with the `/queue` inspection hint — minimal has no
|
||||
/// interactive queue pane, so without the hint the count is a dead end.
|
||||
/// Running `/queue` mid-turn commits the read-only snapshot listing the queued
|
||||
/// text. Standalone (no leader), so the queue is the client-local
|
||||
/// pending-prompts list. The queued prompt then promotes and runs once the
|
||||
/// first turn finishes.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore]
|
||||
async fn minimal_queue_indicator_shows_while_running() {
|
||||
let content = ContentController::start().await.expect("start content");
|
||||
// Pace turn 1 so it's still streaming when we queue behind it.
|
||||
content.set_chunk_delay(Some(Duration::from_millis(150)));
|
||||
content.set_turns([
|
||||
slow_turn_text("STEPONE"),
|
||||
"STEPTWO queued prompt handled.".to_owned(),
|
||||
]);
|
||||
|
||||
let mut harness = spawn_minimal(&content);
|
||||
wait_minimal_ready(&mut harness);
|
||||
|
||||
harness
|
||||
.inject_keys(format!("{PROMPT}\r").as_bytes())
|
||||
.expect("submit prompt");
|
||||
harness
|
||||
.wait_for_text("STEPONE", Duration::from_secs(30))
|
||||
.expect("turn 1 streaming in the live tail");
|
||||
|
||||
// Enter with a draft while the turn runs → queue it behind the running turn.
|
||||
harness
|
||||
.inject_keys(b"second queued prompt\r")
|
||||
.expect("queue a prompt mid-turn");
|
||||
// The count and the inspection hint share the info row: "1 queued · /queue".
|
||||
harness
|
||||
.wait_for_text("1 queued \u{b7} /queue", Duration::from_secs(10))
|
||||
.expect("info row reports the queued count with the /queue hint");
|
||||
|
||||
// `/queue` (slash commands run immediately, they don't queue) commits the
|
||||
// read-only snapshot listing the queued prompt.
|
||||
inject_keys_paced(&mut harness, b"/queue");
|
||||
harness.inject_keys(b"\r").expect("run /queue");
|
||||
harness
|
||||
.wait_for_full_text("Queued prompt", Duration::from_secs(10))
|
||||
.expect("/queue commits the queue snapshot block");
|
||||
harness
|
||||
.wait_for_full_text("#1 second queued prompt", Duration::from_secs(10))
|
||||
.expect("the snapshot lists the queued text");
|
||||
|
||||
// The queued prompt eventually promotes and runs as turn 2.
|
||||
harness
|
||||
.wait_for_full_text("STEPTWO", Duration::from_secs(40))
|
||||
.expect("queued prompt promoted and ran");
|
||||
assert!(
|
||||
!harness.contains_text("panicked"),
|
||||
"pager panicked\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
|
||||
quit_minimal(&mut harness);
|
||||
}
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
// Per-test-case module for the `pty_e2e` integration test crate.
|
||||
#[allow(unused_imports)]
|
||||
use crate::common::*;
|
||||
|
||||
/// Resize hazard: minimal mode uses only the built-in viewport
|
||||
/// machinery and never re-emits committed history (`resize_purge_rerender` /
|
||||
/// `emit_to_scrollback` are forbidden). The terminal reflows committed
|
||||
/// scrollback natively on resize. This commits a tall response into native
|
||||
/// scrollback, resizes the terminal smaller (rows AND cols), and asserts the
|
||||
/// committed content survives (not wiped, not double-printed), the pager does
|
||||
/// not panic or exit, and the prompt is still functional afterwards.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore]
|
||||
async fn minimal_resize_preserves_committed_scrollback() {
|
||||
let content = ContentController::start().await.expect("start content");
|
||||
// Sentinel on the first rendered row; 80 code-block rows >> screen, so the
|
||||
// head scrolls into native scrollback once the block commits. (Prose would
|
||||
// markdown-reflow into one short on-screen paragraph — see `tall_response`.)
|
||||
content.set_response(tall_response(MOCK_RESPONSE_SENTINEL, 80));
|
||||
|
||||
let mut harness = spawn_minimal(&content);
|
||||
wait_minimal_ready(&mut harness);
|
||||
harness
|
||||
.inject_keys(format!("{PROMPT}\r").as_bytes())
|
||||
.expect("submit prompt");
|
||||
|
||||
// Precondition: the committed head must be in native scrollback first.
|
||||
let deadline = Instant::now() + Duration::from_secs(40);
|
||||
while Instant::now() < deadline && !harness.scrollback_text().contains(MOCK_RESPONSE_SENTINEL) {
|
||||
harness.update(Duration::from_millis(100));
|
||||
}
|
||||
assert!(
|
||||
harness.scrollback_text().contains(MOCK_RESPONSE_SENTINEL),
|
||||
"precondition: committed block must reach scrollback before the resize\nscrollback:\n{}",
|
||||
harness.scrollback_text()
|
||||
);
|
||||
|
||||
// Resize smaller in both dimensions. The terminal reflows committed history
|
||||
// natively; minimal must neither reprint (double-print) nor wipe it.
|
||||
harness.resize(30, 80).expect("resize smaller");
|
||||
harness.update(Duration::from_millis(800));
|
||||
|
||||
assert!(
|
||||
harness.is_running(),
|
||||
"pager exited during resize\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
assert!(
|
||||
!harness.contains_text("panicked"),
|
||||
"pager panicked after resize\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
assert!(
|
||||
harness.contains_full_text(MOCK_RESPONSE_SENTINEL),
|
||||
"committed content must survive the resize reflow\nfull:\n{}",
|
||||
harness.full_text()
|
||||
);
|
||||
|
||||
// The prompt is still functional: a second turn streams after the resize.
|
||||
content.set_response(format!("{} after resize.", turn_sentinel(2)));
|
||||
harness
|
||||
.inject_keys(b"again\r")
|
||||
.expect("submit a second turn after resize");
|
||||
harness
|
||||
.wait_for_full_text(&turn_sentinel(2), Duration::from_secs(30))
|
||||
.expect("second turn streams after the resize");
|
||||
|
||||
quit_minimal(&mut harness);
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
// Per-test-case module for the `pty_e2e` integration test crate.
|
||||
#[allow(unused_imports)]
|
||||
use crate::common::*;
|
||||
|
||||
/// `/settings` opens the full settings editor inline in minimal mode —
|
||||
/// hosted in the grown live viewport, reusing the real `render_settings_modal`
|
||||
/// so behavior matches the full TUI — and Esc closes it back to the prompt.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore]
|
||||
async fn minimal_settings_modal_opens_and_closes() {
|
||||
let content = ContentController::start().await.expect("start content");
|
||||
let mut harness = spawn_minimal(&content);
|
||||
wait_minimal_ready(&mut harness);
|
||||
|
||||
// Open the settings editor (paced so the slash dropdown opens rather than
|
||||
// the bytes paste-coalescing), then submit.
|
||||
inject_keys_paced(&mut harness, b"/settings");
|
||||
harness.inject_keys(b"\r").expect("submit /settings");
|
||||
|
||||
// "Appearance" is the first settings category header — it renders only in
|
||||
// the settings editor, never in the status line or the slash dropdown.
|
||||
harness
|
||||
.wait_for_text("Appearance", Duration::from_secs(10))
|
||||
.expect("settings editor renders inline");
|
||||
|
||||
// Esc closes it; the idle prompt status returns and the editor is gone.
|
||||
harness.inject_keys(keys::ESC).expect("close settings");
|
||||
harness
|
||||
.wait_for_text(MINIMAL_IDLE_SENTINEL, Duration::from_secs(10))
|
||||
.expect("settings closed, back to the prompt");
|
||||
assert!(
|
||||
!harness.contains_text("Appearance"),
|
||||
"settings editor must be gone after Esc\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
assert!(
|
||||
!harness.contains_text("panicked"),
|
||||
"pager panicked\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
|
||||
quit_minimal(&mut harness);
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
// Per-test-case module for the `pty_e2e` integration test crate.
|
||||
#[allow(unused_imports)]
|
||||
use crate::common::*;
|
||||
|
||||
/// Minimal mode surfaces the Shift+Tab session mode in the one-line info bar
|
||||
/// directly under the prompt. The mode cycle itself is shared with the full TUI,
|
||||
/// but minimal had no persistent indicator, so pressing Shift+Tab "did nothing
|
||||
/// visible" (dogfood nit). The first press (Normal → Plan, gate-independent)
|
||||
/// must light a lowercase `plan` flag in the info bar that
|
||||
/// `crate::minimal::live::render_prompt_info` draws below the prompt.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore]
|
||||
async fn minimal_shift_tab_shows_mode_in_info_bar() {
|
||||
let content = ContentController::start().await.expect("start content");
|
||||
let mut harness = spawn_minimal(&content);
|
||||
wait_minimal_ready(&mut harness);
|
||||
|
||||
// Baseline: nothing on the idle screen says "plan" — the welcome card hint
|
||||
// is "/help for commands …", the status line "minimal · /help", and the
|
||||
// empty-prompt placeholder "Build anything". So a lowercase "plan" can only
|
||||
// come from the mode flag under test. (The shell's transient
|
||||
// "Switched to mode: Plan" banner uses a capital P, which we don't match.)
|
||||
assert!(
|
||||
!harness.contains_text("plan"),
|
||||
"precondition: idle minimal screen must not already show 'plan'\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
|
||||
// Shift+Tab → BackTab (CSI Z). First press cycles Normal → Plan.
|
||||
harness.inject_keys(b"\x1b[Z").expect("inject BackTab");
|
||||
harness
|
||||
.wait_for_text("plan", Duration::from_secs(10))
|
||||
.expect("plan flag in the info bar under the prompt after Shift+Tab");
|
||||
|
||||
assert!(
|
||||
!harness.contains_text("panicked"),
|
||||
"pager panicked\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
|
||||
quit_minimal(&mut harness);
|
||||
}
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
// Per-test-case module for the `pty_e2e` integration test crate.
|
||||
#[allow(unused_imports)]
|
||||
use crate::common::*;
|
||||
|
||||
/// Content-anchored live region: a response that FITS on the
|
||||
/// screen stays on the visible static band with the prompt directly after it. It
|
||||
/// is NOT force-pushed to the bottom of the screen — the earlier bottom-pin
|
||||
/// behavior left a large blank gap *above* a short conversation (the reported
|
||||
/// regression: "you see a big gap … input snapped to the bottom").
|
||||
///
|
||||
/// Discriminating signals (all robust to how the emulator pads blank rows):
|
||||
/// - the response stays on the visible screen, and is NOT pushed into native
|
||||
/// scrollback (a response that fits never needs to scroll);
|
||||
/// - the always-focused prompt (the cursor) sits HIGH on the screen, directly
|
||||
/// after the short conversation, with the rest of the window left blank below
|
||||
/// it — bottom-pin would instead put the cursor near the last row.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore]
|
||||
async fn minimal_short_response_stays_on_screen() {
|
||||
let content = ContentController::start().await.expect("start content");
|
||||
// A short answer — a couple of rendered rows, far shorter than the 50-row
|
||||
// screen, so it never needs to scroll into native history.
|
||||
content.set_response(format!(
|
||||
"{MOCK_RESPONSE_SENTINEL} — short answer that fits."
|
||||
));
|
||||
|
||||
let mut harness = spawn_minimal(&content);
|
||||
wait_minimal_ready(&mut harness);
|
||||
|
||||
harness
|
||||
.inject_keys(format!("{PROMPT}\r").as_bytes())
|
||||
.expect("submit prompt");
|
||||
|
||||
// Wait for the response to render on the visible screen, then let the turn
|
||||
// finish and the commit settle.
|
||||
harness
|
||||
.wait_for_text(MOCK_RESPONSE_SENTINEL, Duration::from_secs(30))
|
||||
.expect("short response renders on screen");
|
||||
harness.update(Duration::from_millis(400));
|
||||
|
||||
let rows = DEFAULT_ROWS;
|
||||
|
||||
// 1) The short response is on the visible screen …
|
||||
assert!(
|
||||
harness.screen_contents().contains(MOCK_RESPONSE_SENTINEL),
|
||||
"short response must stay on the visible screen\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
// 2) … and was NOT force-pushed into native scrollback. Content-anchored: a
|
||||
// response that fits stays put; only content taller than the screen scrolls
|
||||
// (proven separately by `minimal_commits_response_to_scrollback`).
|
||||
assert!(
|
||||
!harness.scrollback_text().contains(MOCK_RESPONSE_SENTINEL),
|
||||
"short response must not be pushed into scrollback\nscrollback:\n{}",
|
||||
harness.scrollback_text()
|
||||
);
|
||||
|
||||
// 3) The prompt sits directly after the (short) conversation, HIGH on the
|
||||
// screen, with the rest of the window left blank below it — NOT pinned to
|
||||
// the bottom with a big gap above (the regression). The cursor is always
|
||||
// on the focused prompt, so its row is the robust signal: bottom-pin puts
|
||||
// it near `rows - 1`; content-anchored keeps it in the upper portion.
|
||||
let (cursor_row, _cursor_col) = harness.cursor_position();
|
||||
assert!(
|
||||
cursor_row < rows - 12,
|
||||
"prompt/cursor should sit high on the screen (content-anchored), not \
|
||||
pinned near the bottom: cursor_row={cursor_row}, rows={rows}\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
|
||||
// 4) Nothing is rendered near the bottom of the screen: the last non-blank
|
||||
// row (the prompt's info bar) is well above the last row. Found explicitly
|
||||
// (not via trailing padding) so the check is independent of how the
|
||||
// emulator represents empty rows.
|
||||
let screen = harness.screen_contents();
|
||||
let last_non_blank = screen
|
||||
.lines()
|
||||
.enumerate()
|
||||
.filter(|(_, l)| !l.trim().is_empty())
|
||||
.map(|(i, _)| i)
|
||||
.last()
|
||||
.unwrap_or(0);
|
||||
assert!(
|
||||
last_non_blank < (rows as usize) - 10,
|
||||
"content-anchored live region must leave the bottom of the screen blank; \
|
||||
last non-blank row was {last_non_blank} of {rows}\nscreen:\n{screen}"
|
||||
);
|
||||
|
||||
assert!(
|
||||
!harness.contains_text("panicked"),
|
||||
"pager panicked\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
|
||||
quit_minimal(&mut harness);
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
// Per-test-case module for the `pty_e2e` integration test crate.
|
||||
#[allow(unused_imports)]
|
||||
use crate::common::*;
|
||||
|
||||
/// Minimal overlay host: typing `/` opens a prompt-anchored slash
|
||||
/// dropdown above the prompt (growing the pinned live viewport to make room),
|
||||
/// and a single Esc dismisses it. The pane-level slash handler must consume the
|
||||
/// Esc before the idle clear / rewind policy ever runs.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore]
|
||||
async fn minimal_slash_dropdown_dismisses_with_esc() {
|
||||
let content = ContentController::start().await.expect("start content");
|
||||
let mut harness = spawn_minimal(&content);
|
||||
wait_minimal_ready(&mut harness);
|
||||
|
||||
// "/mod" narrows to `/model`, whose description renders only inside the
|
||||
// dropdown — not in the typed text nor the `minimal · /help` status line —
|
||||
// so it's an unambiguous "dropdown is open" sentinel.
|
||||
inject_keys_paced(&mut harness, b"/mod");
|
||||
harness
|
||||
.wait_for_text("Switch the active model", Duration::from_secs(10))
|
||||
.expect("slash dropdown open above the prompt");
|
||||
|
||||
harness.inject_keys(keys::ESC).expect("press esc");
|
||||
harness.update(Duration::from_millis(400));
|
||||
|
||||
let screen = harness.screen_contents();
|
||||
assert!(
|
||||
!screen.contains("Switch the active model"),
|
||||
"Esc must dismiss the slash dropdown\nscreen:\n{screen}"
|
||||
);
|
||||
// Dismiss only — Esc must not have armed the idle clear or opened rewind.
|
||||
assert!(
|
||||
!screen.contains("press again to clear"),
|
||||
"slash-dropdown Esc must not fall through to the idle clear\nscreen:\n{screen}"
|
||||
);
|
||||
assert!(
|
||||
!harness.contains_text("panicked"),
|
||||
"pager panicked\nscreen:\n{screen}"
|
||||
);
|
||||
|
||||
quit_minimal(&mut harness);
|
||||
}
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
// Per-test-case module for the `pty_e2e` integration test crate.
|
||||
#[allow(unused_imports)]
|
||||
use crate::common::*;
|
||||
|
||||
/// `/minimal` from a fullscreen session re-execs the pager with `--minimal
|
||||
/// --resume <id>` so the same conversation reopens under scrollback-native
|
||||
/// rendering. Proves the end-to-end screen-mode switch path (slash command →
|
||||
/// quit → exec → resume in minimal) that unit tests cannot cover.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore]
|
||||
async fn minimal_slash_switches_from_fullscreen() {
|
||||
let content = ContentController::start().await.expect("start content");
|
||||
let sentinel = turn_sentinel(1);
|
||||
content.set_response(format!("{sentinel} fullscreen payload."));
|
||||
|
||||
// Stable project dir so the resumed session is findable by id after re-exec.
|
||||
let project = tempfile::tempdir().expect("create project dir");
|
||||
std::fs::create_dir_all(project.path().join(".git")).expect("create .git");
|
||||
|
||||
let binary = pager_binary().expect("resolve pager binary");
|
||||
// Start fullscreen (default), standalone. Enable query responses *before*
|
||||
// the re-exec so the post-switch minimal probe does not silently downgrade
|
||||
// to full-height inline.
|
||||
let mut harness = PtyHarness::spawn_with_content_in_dir(
|
||||
&binary,
|
||||
DEFAULT_ROWS,
|
||||
DEFAULT_COLS,
|
||||
&content,
|
||||
&["--no-leader"],
|
||||
Some(project.path()),
|
||||
)
|
||||
.expect("spawn fullscreen pager");
|
||||
harness.set_respond_to_queries(true);
|
||||
|
||||
harness
|
||||
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
|
||||
.expect("welcome text");
|
||||
|
||||
// Establish a real session with content so `--resume` has history to load.
|
||||
harness
|
||||
.inject_keys(format!("{PROMPT}\r").as_bytes())
|
||||
.expect("submit turn");
|
||||
harness
|
||||
.wait_for_text(&sentinel, Duration::from_secs(30))
|
||||
.expect("mock response in fullscreen");
|
||||
|
||||
// Switch: `/minimal` should re-exec into scrollback-native mode with the
|
||||
// same session. Pace keystrokes so the slash dropdown opens rather than
|
||||
// paste-coalescing, then confirm once the description row is visible.
|
||||
inject_keys_paced(&mut harness, b"/minimal");
|
||||
harness
|
||||
.wait_for_text(
|
||||
"Reopen this session in minimal (scrollback-native) mode",
|
||||
Duration::from_secs(5),
|
||||
)
|
||||
.expect("slash dropdown offers /minimal");
|
||||
harness.update(Duration::from_millis(150));
|
||||
harness.inject_keys(b"\r").expect("submit /minimal");
|
||||
|
||||
// After the relaunch the PTY stays live (Unix: same process via `exec`;
|
||||
// Windows: child on the same console with the parent parked in `wait`);
|
||||
// wait for minimal's idle status. A `/minimal` re-exec shows the
|
||||
// switch-back form (`… /fullscreen to go back · /help`), not the cold-start
|
||||
// `minimal · /help` sentinel alone.
|
||||
harness
|
||||
.wait_for_text(MINIMAL_SWITCH_BACK_IDLE_SENTINEL, Duration::from_secs(45))
|
||||
.unwrap_or_else(|e| {
|
||||
panic!(
|
||||
"/minimal did not reopen session in minimal mode: {e}\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
)
|
||||
});
|
||||
harness
|
||||
.wait_for_full_text(&sentinel, Duration::from_secs(30))
|
||||
.unwrap_or_else(|e| {
|
||||
panic!(
|
||||
"prior turn must be present after /minimal resume: {e}\nfull:\n{}",
|
||||
harness.full_text()
|
||||
)
|
||||
});
|
||||
|
||||
// Main-screen clear on relaunch: "Reopening session…" was printed just
|
||||
// before exec and must not remain above the resumed UI (the clear wipes
|
||||
// residual main-buffer detritus so the welcome card sits at the top).
|
||||
let screen = harness.screen_contents();
|
||||
assert!(
|
||||
!screen.contains("Reopening session"),
|
||||
"main screen should be cleared on /minimal relaunch; leftover reopen text:\n{screen}"
|
||||
);
|
||||
assert!(
|
||||
screen.contains("Grok Build") || harness.full_text().contains("Grok Build"),
|
||||
"welcome card should re-anchor at top after /minimal relaunch\nscreen:\n{screen}"
|
||||
);
|
||||
|
||||
assert!(
|
||||
!harness.contains_text("panicked"),
|
||||
"pager panicked after /minimal\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
|
||||
quit_minimal(&mut harness);
|
||||
}
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
// Per-test-case module for the `pty_e2e` integration test crate.
|
||||
#[allow(unused_imports)]
|
||||
use crate::common::*;
|
||||
|
||||
/// `/fullscreen` from a minimal session re-execs the pager without `--minimal`
|
||||
/// and with `--resume <id>`, reopening the same conversation under the
|
||||
/// fullscreen alt-screen TUI. The reverse of `minimal_slash_switches_from_fullscreen`.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore]
|
||||
async fn minimal_slash_switches_to_fullscreen() {
|
||||
let content = ContentController::start().await.expect("start content");
|
||||
let sentinel = turn_sentinel(1);
|
||||
content.set_response(format!("{sentinel} minimal payload."));
|
||||
|
||||
let project = tempfile::tempdir().expect("create project dir");
|
||||
std::fs::create_dir_all(project.path().join(".git")).expect("create .git");
|
||||
|
||||
let mut harness =
|
||||
spawn_minimal_in_dir(&content, DEFAULT_ROWS, DEFAULT_COLS, &[], project.path());
|
||||
wait_minimal_ready(&mut harness);
|
||||
|
||||
harness
|
||||
.inject_keys(format!("{PROMPT}\r").as_bytes())
|
||||
.expect("submit turn");
|
||||
harness
|
||||
.wait_for_full_text(&sentinel, Duration::from_secs(30))
|
||||
.expect("turn committed in minimal");
|
||||
|
||||
// Switch back to fullscreen. Wait for the dropdown row so Enter confirms
|
||||
// the command (not a bare paste of the text).
|
||||
inject_keys_paced(&mut harness, b"/fullscreen");
|
||||
harness
|
||||
.wait_for_text(
|
||||
"Reopen this session in fullscreen mode",
|
||||
Duration::from_secs(5),
|
||||
)
|
||||
.expect("slash dropdown offers /fullscreen");
|
||||
harness.update(Duration::from_millis(150));
|
||||
harness.inject_keys(b"\r").expect("submit /fullscreen");
|
||||
|
||||
// Prior turn content is already on the minimal screen, so we cannot use
|
||||
// `wait_for_text(sentinel)` as the transition signal — it would return
|
||||
// immediately. Wait until the minimal idle status line is gone (proves we
|
||||
// left scrollback-native mode) while the prior turn remains visible.
|
||||
let deadline = Instant::now() + Duration::from_secs(45);
|
||||
loop {
|
||||
harness.update(Duration::from_millis(100));
|
||||
let screen = harness.screen_contents();
|
||||
let left_minimal = !screen.contains(MINIMAL_IDLE_SENTINEL)
|
||||
&& !screen.contains(MINIMAL_SWITCH_BACK_IDLE_SENTINEL)
|
||||
&& !screen.contains("Reopen this session in fullscreen mode");
|
||||
let history_present = screen.contains(&sentinel) || harness.full_text().contains(&sentinel);
|
||||
if left_minimal && history_present {
|
||||
break;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
panic!(
|
||||
"/fullscreen did not leave minimal mode with history intact\nscreen:\n{}\nfull:\n{}",
|
||||
harness.screen_contents(),
|
||||
harness.full_text()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
assert!(
|
||||
!harness.contains_text("panicked"),
|
||||
"pager panicked after /fullscreen\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
|
||||
// Slash-command mode switches are session-scoped: `/fullscreen` relaunch
|
||||
// must not write `[ui] screen_mode` (manual config only).
|
||||
let config_path = content.home().join(".grok").join("config.toml");
|
||||
// Brief settle so a fire-and-forget write would have landed if still present.
|
||||
let deadline = Instant::now() + Duration::from_secs(2);
|
||||
while Instant::now() < deadline {
|
||||
harness.update(Duration::from_millis(100));
|
||||
}
|
||||
let body = std::fs::read_to_string(&config_path).unwrap_or_default();
|
||||
assert!(
|
||||
!body.contains("screen_mode"),
|
||||
"/fullscreen must not persist [ui] screen_mode; config.toml:\n{body}"
|
||||
);
|
||||
|
||||
harness.quit().expect("clean quit");
|
||||
}
|
||||
|
|
@ -0,0 +1,111 @@
|
|||
// Per-test-case module for the `pty_e2e` integration test crate.
|
||||
#[allow(unused_imports)]
|
||||
use crate::common::*;
|
||||
|
||||
/// Reasoning text streamed by the mock. Must never appear in the answer text
|
||||
/// so screen assertions can tell the two apart.
|
||||
const REASONING_SENTINEL: &str = "REASONINGSENTINEL";
|
||||
|
||||
/// Dogfood bug: "I don't see thoughts in the transcript". With thinking
|
||||
/// enabled (`[ui] show_thinking_blocks` — the default, set
|
||||
/// explicitly here so the test doesn't depend on the rollout default),
|
||||
/// minimal commits reasoning as a **collapsed** `Thought for Xs` header
|
||||
/// (print-once display policy) — the body is intentionally not in the live
|
||||
/// scrollback. The advertised full-fidelity `/transcript` view must therefore
|
||||
/// render the thinking body **expanded**, or the reasoning is unreachable.
|
||||
///
|
||||
/// Flow: stream a reasoning+text turn → the answer commits, the reasoning
|
||||
/// collapses to its header (body nowhere on screen) → `/transcript` with
|
||||
/// `PAGER=cat` dumps the full view → the reasoning body appears.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore]
|
||||
async fn minimal_transcript_expands_collapsed_thinking() {
|
||||
// The model must run on the Responses backend — reasoning summary deltas
|
||||
// are a Responses-API stream shape (the scripted events below).
|
||||
let content = ContentController::start_with_models(vec![
|
||||
MockModel::new("test-model").with_api_backend("responses"),
|
||||
])
|
||||
.await
|
||||
.expect("start content");
|
||||
// The scripted turn streams reasoning deltas before the visible answer.
|
||||
// Two copies so an auxiliary request can't starve the prompt turn
|
||||
// (consumed FIFO; unconsumed scripts are dropped with the server).
|
||||
let reasoning = format!("{REASONING_SENTINEL} pondering syllables quietly");
|
||||
let answer = format!("{MOCK_RESPONSE_SENTINEL} the answer body.");
|
||||
for _ in 0..2 {
|
||||
content.enqueue_response(
|
||||
"/v1/responses",
|
||||
ScriptedResponse::sse(sse::responses_api_reasoning_and_text_events(
|
||||
&reasoning,
|
||||
&answer,
|
||||
"test-model",
|
||||
)),
|
||||
);
|
||||
}
|
||||
// Fallback mode for any further auxiliary traffic.
|
||||
content.set_response(answer.clone());
|
||||
|
||||
// Thinking blocks explicitly ON (ingestion is gated on this toggle; the
|
||||
// sandbox `$HOME` starts with no config at all).
|
||||
std::fs::create_dir_all(content.home().join(".grok")).expect("mk .grok");
|
||||
std::fs::write(
|
||||
content.home().join(".grok/config.toml"),
|
||||
"[ui]\nshow_thinking_blocks = true\n",
|
||||
)
|
||||
.expect("write config");
|
||||
|
||||
// Minimal env + PAGER=cat (non-interactive dump, same as
|
||||
// `minimal_transcript_opens_in_pager`).
|
||||
let mut env = content.env_for_pager();
|
||||
env.push(("PAGER".to_string(), "cat".to_string()));
|
||||
let env_refs: Vec<(&str, &str)> = env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
|
||||
let binary = pager_binary().expect("resolve pager binary");
|
||||
let mut harness = PtyHarness::new(&binary, DEFAULT_ROWS, DEFAULT_COLS, MINIMAL_ARGS, &env_refs)
|
||||
.expect("spawn minimal pager");
|
||||
harness.set_respond_to_queries(true);
|
||||
|
||||
wait_minimal_ready(&mut harness);
|
||||
|
||||
harness
|
||||
.inject_keys(format!("{PROMPT}\r").as_bytes())
|
||||
.expect("submit prompt");
|
||||
harness
|
||||
.wait_for_full_text(MOCK_RESPONSE_SENTINEL, Duration::from_secs(30))
|
||||
.expect("turn committed");
|
||||
|
||||
// The reasoning committed as its collapsed header: the body is NOT in the
|
||||
// live view (that's the print-once display policy, not a bug)…
|
||||
harness
|
||||
.wait_for_full_text("Thought for", Duration::from_secs(10))
|
||||
.expect("collapsed thinking header committed");
|
||||
assert!(
|
||||
!harness.full_text().contains(REASONING_SENTINEL),
|
||||
"reasoning body must be collapsed in the live view\nfull:\n{}",
|
||||
harness.full_text()
|
||||
);
|
||||
|
||||
// …so the transcript is the only way to read it. cat dumps the full view.
|
||||
inject_keys_paced(&mut harness, b"/transcript");
|
||||
harness.inject_keys(b"\r").expect("submit /transcript");
|
||||
|
||||
harness
|
||||
.wait_for_full_text(REASONING_SENTINEL, Duration::from_secs(15))
|
||||
.unwrap_or_else(|e| {
|
||||
panic!(
|
||||
"transcript must expand the collapsed thinking body: {e}\nfull:\n{}",
|
||||
harness.full_text()
|
||||
)
|
||||
});
|
||||
|
||||
// And the inline TUI survives the suspend/restore round trip.
|
||||
harness
|
||||
.wait_for_text(MINIMAL_IDLE_SENTINEL, Duration::from_secs(10))
|
||||
.expect("inline TUI restored after the pager exited");
|
||||
assert!(
|
||||
!harness.contains_text("panicked"),
|
||||
"pager panicked\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
|
||||
quit_minimal(&mut harness);
|
||||
}
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
// Per-test-case module for the `pty_e2e` integration test crate.
|
||||
#[allow(unused_imports)]
|
||||
use crate::common::*;
|
||||
|
||||
/// Minimal full view: `/transcript` renders the WHOLE conversation
|
||||
/// fully expanded (reasoning in full, tool output uncapped) as ANSI to a temp
|
||||
/// file and opens it in `$PAGER`, suspending the inline TUI, then restores. We
|
||||
/// set `PAGER=cat` so the child dumps the transcript and exits immediately (no
|
||||
/// interactive `less` to drive). Proof the pager ran on the transcript: the
|
||||
/// turn's sentinel then appears **twice** — once in the live conversation and
|
||||
/// once in the dumped transcript — and the inline TUI restores to idle after.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore]
|
||||
async fn minimal_transcript_opens_in_pager() {
|
||||
let content = ContentController::start().await.expect("start content");
|
||||
content.set_response(format!("{MOCK_RESPONSE_SENTINEL} transcript body."));
|
||||
|
||||
// Minimal env + PAGER=cat (non-interactive). Response forwarding on so the
|
||||
// inline-viewport cursor probe completes (see spawn_minimal).
|
||||
let mut env = content.env_for_pager();
|
||||
env.push(("PAGER".to_string(), "cat".to_string()));
|
||||
let env_refs: Vec<(&str, &str)> = env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
|
||||
let binary = pager_binary().expect("resolve pager binary");
|
||||
let mut harness = PtyHarness::new(&binary, DEFAULT_ROWS, DEFAULT_COLS, MINIMAL_ARGS, &env_refs)
|
||||
.expect("spawn minimal pager");
|
||||
harness.set_respond_to_queries(true);
|
||||
|
||||
wait_minimal_ready(&mut harness);
|
||||
|
||||
// Produce a turn so there is something to transcribe.
|
||||
harness
|
||||
.inject_keys(format!("{PROMPT}\r").as_bytes())
|
||||
.expect("submit prompt");
|
||||
harness
|
||||
.wait_for_full_text(MOCK_RESPONSE_SENTINEL, Duration::from_secs(30))
|
||||
.expect("turn committed");
|
||||
|
||||
// Open the transcript in $PAGER (cat dumps it and exits).
|
||||
inject_keys_paced(&mut harness, b"/transcript");
|
||||
harness.inject_keys(b"\r").expect("submit /transcript");
|
||||
|
||||
// The pager (cat) dumps the full transcript, which re-emits the turn body,
|
||||
// so the sentinel now appears at least twice across scrollback + screen (the
|
||||
// live turn plus the dumped transcript). This proves the pager ran on the
|
||||
// rendered transcript rather than the command being a no-op.
|
||||
let deadline = Instant::now() + Duration::from_secs(15);
|
||||
while Instant::now() < deadline
|
||||
&& harness.full_text().matches(MOCK_RESPONSE_SENTINEL).count() < 2
|
||||
{
|
||||
harness.update(Duration::from_millis(100));
|
||||
}
|
||||
assert!(
|
||||
harness.full_text().matches(MOCK_RESPONSE_SENTINEL).count() >= 2,
|
||||
"transcript must be dumped by $PAGER (sentinel should appear in both the \
|
||||
live turn and the dumped transcript)\nfull:\n{}",
|
||||
harness.full_text()
|
||||
);
|
||||
|
||||
// The pager process survives the suspend/restore round-trip and returns to
|
||||
// the idle prompt.
|
||||
harness
|
||||
.wait_for_text(MINIMAL_IDLE_SENTINEL, Duration::from_secs(10))
|
||||
.expect("inline TUI restored after the pager exited");
|
||||
assert!(
|
||||
!harness.contains_text("panicked"),
|
||||
"pager panicked\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
|
||||
quit_minimal(&mut harness);
|
||||
}
|
||||
|
|
@ -0,0 +1,137 @@
|
|||
// Per-test-case module for the `pty_e2e` integration test crate.
|
||||
#[allow(unused_imports)]
|
||||
use crate::common::*;
|
||||
|
||||
/// Frame-write delay (ms) widening the suspend-vs-queued-frame race window:
|
||||
/// the writer thread holds every frame this long, so any frame still queued
|
||||
/// when the suspend runs is guaranteed to land during the `$PAGER` child's
|
||||
/// tenure (on its alternate screen) unless the pre-suspend drain waits for it.
|
||||
const FRAME_DELAY_MS: &str = "40";
|
||||
|
||||
/// Dogfood bug: after returning from the minimal `/transcript` pager the live
|
||||
/// region was "off by one" — a stale status row above a freshly drawn prompt,
|
||||
/// stray `[` escape fragments, the cursor on the info row. Root cause: frames
|
||||
/// are written to the tty by an async writer thread, and the suspend path
|
||||
/// never drained it — the frame that armed the suspend (plus any composer
|
||||
/// clear / viewport scroll frames queued just before) raced `less`, landing on
|
||||
/// its alternate screen. The main screen never received those writes, so after
|
||||
/// `rmcup` restored it, the renderer's diff no longer described reality and
|
||||
/// the stale rows were never repainted.
|
||||
///
|
||||
/// The fix drains the writer before the child takes the tty (deterministic
|
||||
/// coverage: the `WriterSync` unit tests in `render::draw`), then re-anchors
|
||||
/// and full-repaints after it exits. This test drives the user-visible round
|
||||
/// trip — burst of composer edits, `/transcript`, a real `less` (alt screen +
|
||||
/// `rmcup`), `q` — under the frame-write delay, and asserts the restored
|
||||
/// screen is exactly the idle live region: one status row, one info row, no
|
||||
/// stale draft/command text, no torn escape fragments.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore]
|
||||
async fn minimal_transcript_pager_restore_no_artifacts() {
|
||||
// A real interactive pager is the point (alt screen + rmcup restore).
|
||||
if std::process::Command::new("less")
|
||||
.arg("--version")
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status()
|
||||
.is_err()
|
||||
{
|
||||
eprintln!("skipping: `less` not available on this machine");
|
||||
return;
|
||||
}
|
||||
|
||||
let content = ContentController::start().await.expect("start content");
|
||||
content.set_response(format!("{MOCK_RESPONSE_SENTINEL} transcript body."));
|
||||
|
||||
let mut env = content.env_for_pager();
|
||||
env.push(("PAGER".to_string(), "less".to_string()));
|
||||
env.push((
|
||||
"GROK_TEST_FRAME_WRITE_DELAY_MS".to_string(),
|
||||
FRAME_DELAY_MS.to_string(),
|
||||
));
|
||||
let env_refs: Vec<(&str, &str)> = env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
|
||||
let binary = pager_binary().expect("resolve pager binary");
|
||||
let mut harness = PtyHarness::new(&binary, DEFAULT_ROWS, DEFAULT_COLS, MINIMAL_ARGS, &env_refs)
|
||||
.expect("spawn minimal pager");
|
||||
harness.set_respond_to_queries(true);
|
||||
|
||||
wait_minimal_ready(&mut harness);
|
||||
|
||||
// A committed turn so the transcript has content.
|
||||
harness
|
||||
.inject_keys(format!("{PROMPT}\r").as_bytes())
|
||||
.expect("submit prompt");
|
||||
harness
|
||||
.wait_for_full_text(MOCK_RESPONSE_SENTINEL, Duration::from_secs(30))
|
||||
.expect("turn committed");
|
||||
// Let the (delayed) writer queue fully drain at idle so the burst below is
|
||||
// the only thing in flight when the suspend arms.
|
||||
harness.update(Duration::from_secs(3));
|
||||
|
||||
// Queue a burst of composer-changing frames right before the suspend:
|
||||
// type a draft, kill it (Ctrl+U), then type /transcript (slash dropdown
|
||||
// opens + closes: viewport grow/shrink frames) and submit. With the
|
||||
// writer delay, the trailing clear/shrink frames are still queued when
|
||||
// the suspend path runs — the shape of the dogfood report.
|
||||
inject_keys_paced(&mut harness, b"zetaquxdraft");
|
||||
harness.update(Duration::from_millis(120));
|
||||
harness.inject_keys(b"\x15").expect("Ctrl+U kill draft");
|
||||
inject_keys_paced(&mut harness, b"/transcript");
|
||||
harness.inject_keys(b"\r").expect("submit /transcript");
|
||||
|
||||
// less (alt screen, `+G`) is foreground once its status line shows the
|
||||
// transcript temp-file path — the conversation sentinel can't be used as
|
||||
// the signal, it is already visible on the live screen. Then quit it.
|
||||
harness
|
||||
.wait_for_text("grok-transcript-", Duration::from_secs(20))
|
||||
.expect("less shows the transcript");
|
||||
harness.update(Duration::from_millis(300));
|
||||
harness.inject_keys(b"q").expect("quit less");
|
||||
|
||||
// Restored: the idle live region, with no artifacts. Give the resume
|
||||
// redraw (and any stragglers) time to settle before asserting.
|
||||
harness
|
||||
.wait_for_text(MINIMAL_IDLE_SENTINEL, Duration::from_secs(10))
|
||||
.expect("inline TUI restored after less exited");
|
||||
harness.update(Duration::from_secs(2));
|
||||
|
||||
let screen = harness.screen_contents();
|
||||
// Exactly one status row and one info row on the visible screen: a lost
|
||||
// scroll leaves a stale extra copy of one of them (the "off by one" look).
|
||||
assert_eq!(
|
||||
screen.matches(MINIMAL_IDLE_SENTINEL).count(),
|
||||
1,
|
||||
"exactly one idle status row after the pager round trip\nscreen:\n{screen}"
|
||||
);
|
||||
assert_eq!(
|
||||
screen.matches("ctrl+o transcript").count(),
|
||||
1,
|
||||
"exactly one info row after the pager round trip\nscreen:\n{screen}"
|
||||
);
|
||||
// The frames queued right before the suspend (composer kill + /transcript
|
||||
// submit-clear) must have LANDED: without the pre-suspend writer drain
|
||||
// they die on the pager's alternate screen, and the restored main screen
|
||||
// still shows the killed draft / the submitted command in the prompt while
|
||||
// the renderer's diff believes both are gone — exactly the stale-row
|
||||
// corruption from the dogfood report.
|
||||
assert!(
|
||||
!screen.contains("❯ /transcript"),
|
||||
"stale submitted command left in the prompt after the round trip\nscreen:\n{screen}"
|
||||
);
|
||||
assert!(
|
||||
!screen.contains("zetaquxdraft"),
|
||||
"stale killed-draft text left on screen after the round trip\nscreen:\n{screen}"
|
||||
);
|
||||
// No torn-escape fragments at the left margin (the dogfood `[Claud…` row):
|
||||
// no screen row may START with a literal `[`.
|
||||
assert!(
|
||||
!screen.lines().any(|l| l.starts_with('[')),
|
||||
"no torn escape fragments at column 0\nscreen:\n{screen}"
|
||||
);
|
||||
assert!(
|
||||
!harness.contains_text("panicked"),
|
||||
"pager panicked\nscreen:\n{screen}"
|
||||
);
|
||||
|
||||
quit_minimal(&mut harness);
|
||||
}
|
||||
30
crates/codegen/xai-grok-pager/tests/pty_e2e/minimal/mod.rs
Normal file
30
crates/codegen/xai-grok-pager/tests/pty_e2e/minimal/mod.rs
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
//! PTY e2e tests for the experimental `--minimal` (scrollback-native) mode.
|
||||
//!
|
||||
//! Grouped under one `mod minimal;` so the parent `pty_e2e` module isn't
|
||||
//! interleaved with a dozen `minimal_*` entries — a full-pager contributor can
|
||||
//! skip this whole subtree. These exercise the sibling `xai-grok-pager-minimal`
|
||||
//! crate end-to-end through the built binary (which installs the minimal hook),
|
||||
//! so they live with the rest of the pty suite rather than in a separate crate.
|
||||
//! Shared harness helpers are reached via `crate::common` (the family root).
|
||||
|
||||
mod minimal_cli_screen_mode_does_not_persist;
|
||||
mod minimal_commits_response_to_scrollback;
|
||||
mod minimal_committed_content_survives_overlay_grow;
|
||||
mod minimal_continue_reprints_transcript;
|
||||
mod minimal_ctrl_c_arms_and_quits;
|
||||
mod minimal_double_esc_committed_queued_prompt_single_render;
|
||||
mod minimal_esc_mid_turn_is_swallowed;
|
||||
mod minimal_flush_left_no_hpad;
|
||||
mod minimal_help_opens_command_palette;
|
||||
mod minimal_new_session_keeps_history_and_resets;
|
||||
mod minimal_queue_indicator_shows_while_running;
|
||||
mod minimal_resize_preserves_committed_scrollback;
|
||||
mod minimal_settings_modal_opens_and_closes;
|
||||
mod minimal_shift_tab_shows_mode_in_info_bar;
|
||||
mod minimal_short_response_stays_on_screen;
|
||||
mod minimal_slash_dropdown_dismisses_with_esc;
|
||||
mod minimal_slash_switches_from_fullscreen;
|
||||
mod minimal_slash_switches_to_fullscreen;
|
||||
mod minimal_transcript_expands_collapsed_thinking;
|
||||
mod minimal_transcript_opens_in_pager;
|
||||
mod minimal_transcript_pager_restore_no_artifacts;
|
||||
Loading…
Reference in a new issue