Synced from monorepo
Synced from monorepo Changes: - grok-shell: send an expired external-provider credential to the sign-in flow, not a 401 loop - pager: clickable ▲ jumps to the top of the response being read - grok-shell: keep a large task log from making the completion message too long - Plan viewer scrollbar: widen grab zone to the border column; fix striped thumb in Terminal.app - pager: poll the tmux probe teardown grace instead of sleeping it - security: vendor-compat MCP kill switch is now actually enforced when reported as on - grok-shell: restore session eviction when a leader client disconnects - Bump rust-toolchain to 1.93.0 - workspace: lexical-normalize permission path patterns before glob matching - pager: reject garbage Enter in the /resume picker - pager: show Mermaid affordances in plan mode preview - pager: drop manage-account link from /session-info - workspace: auto-approve read-only git queries; defer write floor to auto classifier - Add free-form pattern editor to the "Always allow" command prompt - grok-shell: fix /btw caching - pager: Tab walks answers in the ask_user_question card - External-provider auth refresh: single 7s attempt instead of 3×5s - pager: don't resurrect finished background tasks as Running when completion arrives first - pager: report tmux truecolor clamping in Doctor - Fix plan viewer scrollbar click+drag hijacked by comment gutter - pager/shell: stop double Recap after the same last turn - sampler: preserve x-should-retry through stream collection - pager: clear plan-mode indicator immediately when the user approves a plan - pager: tmux does not re-read its config on reattach Source-Revision: 64c4de99cc822b25ce9c54ab5a4f372093d0885d
This commit is contained in:
parent
a422116582
commit
780d1388ff
323 changed files with 12258 additions and 7226 deletions
|
|
@ -1251,6 +1251,34 @@ pub(crate) fn write_cast_if_requested(harness: &PtyHarness, file_name: &str) {
|
|||
}
|
||||
}
|
||||
|
||||
/// Dump the current screen (plain text and HTML) into
|
||||
/// `$GROK_PTY_CAST_DIR/<file_stem>.{txt,html}` when the env var is set.
|
||||
/// Failures are logged, never fatal — same opt-in as
|
||||
/// [`write_cast_if_requested`].
|
||||
pub(crate) fn write_screen_dump_if_requested(harness: &PtyHarness, file_stem: &str) {
|
||||
let Ok(dir) = std::env::var("GROK_PTY_CAST_DIR") else {
|
||||
return;
|
||||
};
|
||||
if dir.is_empty() {
|
||||
return;
|
||||
}
|
||||
let dir = std::path::PathBuf::from(dir);
|
||||
if let Err(e) = std::fs::create_dir_all(&dir) {
|
||||
eprintln!("failed to create dump dir {}: {e}", dir.display());
|
||||
return;
|
||||
}
|
||||
for (ext, body) in [
|
||||
("txt", harness.screen_contents()),
|
||||
("html", harness.screen_html()),
|
||||
] {
|
||||
let path = dir.join(format!("{file_stem}.{ext}"));
|
||||
match std::fs::write(&path, body) {
|
||||
Ok(()) => eprintln!("wrote screen dump: {}", path.display()),
|
||||
Err(e) => eprintln!("failed to write screen dump {}: {e}", path.display()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Clipboard paste e2e tests ───────────────────────────────────────────
|
||||
|
||||
/// Serialized `content` of every user message across recorded requests, in
|
||||
|
|
|
|||
|
|
@ -0,0 +1,164 @@
|
|||
#[allow(unused_imports)]
|
||||
use super::common::*;
|
||||
|
||||
const TAG: &str = "SBGRAB";
|
||||
const PLAN_LINES: usize = 120;
|
||||
|
||||
/// PTY: presses, wheels, and drags on the modal border column next to the
|
||||
/// scrollbar track must scroll the plan. Users read the thumb + border as
|
||||
/// one two-column scrollbar and press the border half (reported on macOS
|
||||
/// Terminal.app and ghostty over SSH), which used to fall into the
|
||||
/// click-outside-modal path.
|
||||
///
|
||||
/// Also pins the thumb contract `bg == fg`: Terminal.app leaves line-gap
|
||||
/// pixels unpainted under a foreground-only `█`, striping the thumb with
|
||||
/// dark bars.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore = "PTY e2e; run the owning pty_e2e_* Cargo test with --ignored (see Cargo.toml)"]
|
||||
async fn plan_scrollbar_grab_zone_pty() {
|
||||
let content = ContentController::start().await.expect("start content");
|
||||
content.set_response(format!("{MOCK_RESPONSE_SENTINEL} first turn done."));
|
||||
|
||||
let binary = pager_binary().expect("resolve pager binary");
|
||||
let mut harness = PtyHarness::spawn_with_content_env_in_dir(
|
||||
&binary,
|
||||
DEFAULT_ROWS,
|
||||
DEFAULT_COLS,
|
||||
&content,
|
||||
&["--yolo", "--trust", "--no-leader"],
|
||||
&[],
|
||||
Some(content.home()),
|
||||
)
|
||||
.expect("spawn pager");
|
||||
|
||||
harness
|
||||
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
|
||||
.expect("welcome");
|
||||
harness.inject_keys(b"go\r").expect("first turn");
|
||||
harness
|
||||
.wait_for_text(MOCK_RESPONSE_SENTINEL, Duration::from_secs(40))
|
||||
.expect("first turn streams");
|
||||
|
||||
let dir = session_dir(&content, &mut harness);
|
||||
std::fs::write(dir.join("plan.md"), plan_body(TAG, PLAN_LINES)).expect("seed plan.md");
|
||||
|
||||
let _expectation = expect_tool_turn(&content, "call_plan_sb", "exit_plan_mode", "{}".into());
|
||||
harness
|
||||
.inject_keys(b"present the plan\r")
|
||||
.expect("submit plan prompt");
|
||||
harness
|
||||
.wait_for_text("request changes", Duration::from_secs(60))
|
||||
.unwrap_or_else(|e| {
|
||||
panic!(
|
||||
"plan approval never parked: {e}\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
)
|
||||
});
|
||||
|
||||
let before = harness.screen_contents();
|
||||
assert!(
|
||||
before.contains(&format!("{TAG}000")),
|
||||
"viewer must open at the top of the plan; screen:\n{before}"
|
||||
);
|
||||
let last = format!("{TAG}{:03}", PLAN_LINES - 1);
|
||||
assert!(
|
||||
!before.contains(&last),
|
||||
"the plan tail must start off-screen; screen:\n{before}"
|
||||
);
|
||||
|
||||
let (title_row, _) = locate_screen_text(&before, "plan.md").expect("plan viewer title visible");
|
||||
let border_col = before
|
||||
.lines()
|
||||
.nth(title_row as usize)
|
||||
.map(|l| l.trim_end().chars().count() as u16 - 1)
|
||||
.expect("title row present");
|
||||
let (approve_row, _) = locate_screen_text(&before, "approve").expect("footer approve visible");
|
||||
let track_bottom_row = approve_row - 2;
|
||||
let track_top_row = title_row + 1;
|
||||
|
||||
let track_col = (border_col - 1) as usize;
|
||||
let styled = harness.screen_styled();
|
||||
let mut thumb_cells = 0;
|
||||
for line in &styled {
|
||||
let mut col = 0usize;
|
||||
for run in &line.runs {
|
||||
let run_width = run.text.chars().count();
|
||||
if col <= track_col && track_col < col + run_width && run.text.contains('\u{2588}') {
|
||||
thumb_cells += 1;
|
||||
assert!(run.fg.is_some(), "thumb must have a color (run {run:?})");
|
||||
assert_eq!(
|
||||
run.bg, run.fg,
|
||||
"thumb cell background must match the glyph color (run {run:?})"
|
||||
);
|
||||
}
|
||||
col += run_width;
|
||||
}
|
||||
}
|
||||
assert!(thumb_cells > 0, "scrollbar thumb must be visible");
|
||||
|
||||
let mut click = String::new();
|
||||
click.push_str(&sgr_mouse(0, track_bottom_row, border_col, 'M'));
|
||||
click.push_str(&sgr_mouse(0, track_bottom_row, border_col, 'm'));
|
||||
harness.inject_keys(click.as_bytes()).expect("border click");
|
||||
harness
|
||||
.wait_for_text(&last, Duration::from_secs(10))
|
||||
.unwrap_or_else(|e| {
|
||||
panic!(
|
||||
"border-column click must scroll to the plan tail: {e}\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
)
|
||||
});
|
||||
|
||||
// A single synthetic notch was observed to be swallowed by the
|
||||
// scroll-stream cadence; real wheels emit bursts.
|
||||
let wheel_up: String = (0..6)
|
||||
.map(|_| sgr_mouse(64, track_bottom_row, border_col, 'M'))
|
||||
.collect();
|
||||
harness
|
||||
.inject_keys(wheel_up.as_bytes())
|
||||
.expect("border wheel up");
|
||||
harness
|
||||
.wait_for_text_absent(&last, Duration::from_secs(10))
|
||||
.unwrap_or_else(|e| {
|
||||
panic!(
|
||||
"wheel-up on the border column must scroll the plan: {e}\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
)
|
||||
});
|
||||
|
||||
let mut drag = String::new();
|
||||
drag.push_str(&sgr_mouse(0, track_bottom_row, border_col, 'M'));
|
||||
drag.push_str(&sgr_mouse(
|
||||
32,
|
||||
(track_top_row + track_bottom_row) / 2,
|
||||
border_col,
|
||||
'M',
|
||||
));
|
||||
drag.push_str(&sgr_mouse(32, track_top_row, border_col, 'M'));
|
||||
drag.push_str(&sgr_mouse(0, track_top_row, border_col, 'm'));
|
||||
harness.inject_keys(drag.as_bytes()).expect("border drag");
|
||||
harness
|
||||
.wait_for_text(&format!("{TAG}000"), Duration::from_secs(10))
|
||||
.unwrap_or_else(|e| {
|
||||
panic!(
|
||||
"border-column drag must scroll back to the plan top: {e}\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
)
|
||||
});
|
||||
|
||||
let after = harness.screen_contents();
|
||||
assert!(
|
||||
after.contains("request changes"),
|
||||
"approval chrome must stay open after scrollbar gestures; screen:\n{after}"
|
||||
);
|
||||
assert!(
|
||||
!after.contains("Type your comment...") && !after.contains("commenting L"),
|
||||
"scrollbar gestures must not enter commenting; screen:\n{after}"
|
||||
);
|
||||
assert!(
|
||||
!after.contains("panicked"),
|
||||
"pager panicked\nscreen:\n{after}"
|
||||
);
|
||||
|
||||
harness.quit().expect("clean quit");
|
||||
}
|
||||
|
|
@ -0,0 +1,236 @@
|
|||
// Per-test-case module for the `pty_e2e` integration test crate.
|
||||
//
|
||||
// Regression pin: Tab inside the `ask_user_question` card used to hand focus
|
||||
// to the scrollback while the card stayed drawn and the shortcuts bar kept
|
||||
// advertising card keys.
|
||||
#[allow(unused_imports)]
|
||||
use super::common::*;
|
||||
|
||||
const FIRST_QUESTION: &str = "What kind of work are you most interested in right now?";
|
||||
const SECOND_QUESTION: &str = "How deep should the answer go?";
|
||||
|
||||
/// Answer rows of each question in render order.
|
||||
const FIRST_ROWS: [&str; 4] = [
|
||||
"Writing or editing code",
|
||||
"Explaining the codebase",
|
||||
"Debugging a failure",
|
||||
"Type your answer here",
|
||||
];
|
||||
const SECOND_ROWS: [&str; 3] = ["A quick summary", "Every detail", "Type your answer here"];
|
||||
|
||||
const DONE_SENTINEL: &str = "QUESTIONTABDONE";
|
||||
|
||||
const TAB: &[u8] = b"\t";
|
||||
/// `BackTab` — the xterm encoding of Shift+Tab.
|
||||
const SHIFT_TAB: &[u8] = b"\x1b[Z";
|
||||
|
||||
const FOCUSED_HINT: &str = "Tab:next answer";
|
||||
|
||||
fn ask_user_question_args() -> String {
|
||||
let option =
|
||||
|label: &str, description: &str| json!({ "label": label, "description": description });
|
||||
json!({
|
||||
"questions": [
|
||||
{
|
||||
"question": FIRST_QUESTION,
|
||||
"options": [
|
||||
option(FIRST_ROWS[0], "Implement features or fix bugs"),
|
||||
option(FIRST_ROWS[1], "Understand how things work"),
|
||||
option(FIRST_ROWS[2], "Track down a flake"),
|
||||
],
|
||||
},
|
||||
{
|
||||
"question": SECOND_QUESTION,
|
||||
"options": [
|
||||
option(SECOND_ROWS[0], "Just the headline"),
|
||||
option(SECOND_ROWS[1], "Walk me through it"),
|
||||
],
|
||||
},
|
||||
]
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Text of the answer row that currently carries the cursor band.
|
||||
///
|
||||
/// Reading the styled screen is the only way to observe cursor position from
|
||||
/// outside the process.
|
||||
fn cursor_row(harness: &PtyHarness) -> Option<String> {
|
||||
let labels: Vec<&str> = FIRST_ROWS
|
||||
.iter()
|
||||
.chain(SECOND_ROWS.iter())
|
||||
.copied()
|
||||
.collect();
|
||||
let mut rows: Vec<(String, String)> = Vec::new();
|
||||
for line in harness.screen_styled() {
|
||||
let text: String = line.runs.iter().map(|r| r.text.as_str()).collect();
|
||||
let Some(label) = labels.iter().find(|label| text.contains(**label)) else {
|
||||
continue;
|
||||
};
|
||||
let bg = line
|
||||
.runs
|
||||
.iter()
|
||||
.find(|run| run.text.contains(*label))
|
||||
.and_then(|run| run.bg.clone())
|
||||
.unwrap_or_default();
|
||||
rows.push(((*label).to_string(), bg));
|
||||
}
|
||||
rows.iter()
|
||||
.find(|(_, bg)| rows.iter().filter(|(_, other)| other == bg).count() == 1)
|
||||
.map(|(label, _)| label.clone())
|
||||
}
|
||||
|
||||
fn expect_cursor_row(harness: &mut PtyHarness, expected: &str, step: &str) {
|
||||
let outcome = harness.wait_until(
|
||||
&format!("{step}: cursor on {expected:?}"),
|
||||
CURSOR_TIMEOUT,
|
||||
|h| cursor_row(h).as_deref() == Some(expected),
|
||||
);
|
||||
eprintln!("[cursor] {step}: {:?}", cursor_row(harness));
|
||||
outcome.unwrap_or_else(|e| panic!("{e}"));
|
||||
}
|
||||
|
||||
fn expect_text(harness: &mut PtyHarness, needle: &str, step: &str) {
|
||||
eprintln!("[screen] {step}: waiting for {needle:?}");
|
||||
harness
|
||||
.wait_for_text(needle, CURSOR_TIMEOUT)
|
||||
.unwrap_or_else(|e| panic!("{step}: {e}"));
|
||||
}
|
||||
|
||||
const CURSOR_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
/// Every string leaf of a recorded request body.
|
||||
fn string_leaves(value: &serde_json::Value, out: &mut Vec<String>) {
|
||||
match value {
|
||||
serde_json::Value::String(text) => out.push(text.clone()),
|
||||
serde_json::Value::Array(items) => items.iter().for_each(|item| string_leaves(item, out)),
|
||||
serde_json::Value::Object(fields) => {
|
||||
fields.values().for_each(|field| string_leaves(field, out))
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Drive a real two-question `ask_user_question` card all the way round its
|
||||
/// answer walk, both directions, and submit from where Tab left off.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore = "PTY e2e; run the owning pty_e2e_* Cargo test with --ignored (see Cargo.toml)"]
|
||||
async fn question_tab_cycles_answers() {
|
||||
let content = ContentController::start().await.expect("start content");
|
||||
let _turn = expect_tool_turn(
|
||||
&content,
|
||||
"call_ask_tab",
|
||||
"ask_user_question",
|
||||
ask_user_question_args(),
|
||||
);
|
||||
content.set_response(DONE_SENTINEL);
|
||||
|
||||
let binary = pager_binary().expect("resolve pager binary");
|
||||
let mut harness = PtyHarness::spawn_with_content_in_dir(
|
||||
&binary,
|
||||
DEFAULT_ROWS,
|
||||
DEFAULT_COLS,
|
||||
&content,
|
||||
&["--yolo", "--trust"],
|
||||
Some(content.home()),
|
||||
)
|
||||
.expect("spawn pager with content");
|
||||
|
||||
harness
|
||||
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
|
||||
.expect("welcome text");
|
||||
harness
|
||||
.inject_keys(format!("{PROMPT}\r").as_bytes())
|
||||
.expect("submit prompt");
|
||||
harness
|
||||
.wait_for_text(FIRST_ROWS[2], Duration::from_secs(30))
|
||||
.expect("question card renders");
|
||||
expect_cursor_row(
|
||||
&mut harness,
|
||||
FIRST_ROWS[0],
|
||||
"card opens on the first answer",
|
||||
);
|
||||
write_screen_dump_if_requested(&harness, "question_tab_00_card_open");
|
||||
|
||||
harness.inject_keys(b" ").expect("Space marks the answer");
|
||||
|
||||
for (step, expected) in FIRST_ROWS.iter().enumerate().skip(1) {
|
||||
harness.inject_keys(TAB).expect("Tab");
|
||||
expect_cursor_row(&mut harness, expected, &format!("Tab #{step}"));
|
||||
}
|
||||
write_screen_dump_if_requested(&harness, "question_tab_01_first_question_walked");
|
||||
|
||||
harness.inject_keys(TAB).expect("Tab into question 2");
|
||||
expect_cursor_row(&mut harness, SECOND_ROWS[0], "Tab crosses into question 2");
|
||||
expect_text(
|
||||
&mut harness,
|
||||
"[2/2]",
|
||||
"question counter follows the answer walk",
|
||||
);
|
||||
write_screen_dump_if_requested(&harness, "question_tab_02_second_question");
|
||||
|
||||
harness.inject_keys(SHIFT_TAB).expect("Shift+Tab back");
|
||||
expect_cursor_row(&mut harness, FIRST_ROWS[3], "Shift+Tab crosses back");
|
||||
harness.inject_keys(TAB).expect("Tab into question 2 again");
|
||||
expect_cursor_row(&mut harness, SECOND_ROWS[0], "and forward again");
|
||||
|
||||
for _ in 0..SECOND_ROWS.len() {
|
||||
harness.inject_keys(TAB).expect("Tab");
|
||||
}
|
||||
expect_cursor_row(
|
||||
&mut harness,
|
||||
FIRST_ROWS[0],
|
||||
"Tab past the last answer wraps",
|
||||
);
|
||||
expect_text(&mut harness, "[1/2]", "the wrap lands back on question 1");
|
||||
write_screen_dump_if_requested(&harness, "question_tab_03_wrapped_to_first");
|
||||
assert!(
|
||||
harness.contains_text(FOCUSED_HINT),
|
||||
"the card still owns the keyboard after the wrap\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
|
||||
harness
|
||||
.inject_keys(SHIFT_TAB)
|
||||
.expect("Shift+Tab wraps back");
|
||||
expect_cursor_row(&mut harness, SECOND_ROWS[2], "Shift+Tab wraps to the last");
|
||||
expect_text(
|
||||
&mut harness,
|
||||
"[2/2]",
|
||||
"the backwards wrap lands on question 2",
|
||||
);
|
||||
write_screen_dump_if_requested(&harness, "question_tab_04_wrapped_to_last");
|
||||
|
||||
harness.inject_keys(SHIFT_TAB).expect("Shift+Tab");
|
||||
expect_cursor_row(
|
||||
&mut harness,
|
||||
SECOND_ROWS[1],
|
||||
"Shift+Tab back onto an answer",
|
||||
);
|
||||
harness.inject_keys(b"\r").expect("submit answers");
|
||||
harness
|
||||
.wait_for_text(DONE_SENTINEL, Duration::from_secs(30))
|
||||
.expect("agent turn resumes after the answers");
|
||||
write_screen_dump_if_requested(&harness, "question_tab_99_submitted");
|
||||
|
||||
let mut leaves = Vec::new();
|
||||
for body in content.request_bodies() {
|
||||
string_leaves(&body, &mut leaves);
|
||||
}
|
||||
let answered: Vec<&String> = leaves
|
||||
.iter()
|
||||
.filter(|leaf| leaf.contains("has answered your questions"))
|
||||
.collect();
|
||||
eprintln!("[tool result] {answered:?}");
|
||||
for expected in [
|
||||
format!("\"{FIRST_QUESTION}\"=\"{}\"", FIRST_ROWS[0]),
|
||||
format!("\"{SECOND_QUESTION}\"=\"{}\"", SECOND_ROWS[1]),
|
||||
] {
|
||||
assert!(
|
||||
answered.iter().any(|leaf| leaf.contains(&expected)),
|
||||
"tool result should carry {expected:?}, got {answered:?}"
|
||||
);
|
||||
}
|
||||
|
||||
harness.quit().expect("clean quit");
|
||||
}
|
||||
|
|
@ -0,0 +1,133 @@
|
|||
// Per-test-case module for the `pty_e2e` integration test crate.
|
||||
//
|
||||
// The ▲ jump-to-response-top indicator: an answer taller than the screen
|
||||
// leaves the reader at its tail, and the sticky header's gap row offers a
|
||||
// clickable ▲ that snaps the answer's first line to the top — the mouse
|
||||
// mirror of the K (previous response) key, and the discoverable fix for
|
||||
// "grok doesn't show you its answer from the top, so you have to scroll a
|
||||
// lot".
|
||||
#[allow(unused_imports)]
|
||||
use super::common::*;
|
||||
|
||||
/// First and last line of the streamed answer. 60 bullets over a 50-row
|
||||
/// terminal guarantees the answer overflows the viewport.
|
||||
const FIRST_LINE: &str = "ANSWERLINE001";
|
||||
const LAST_LINE: &str = "ANSWERLINE060";
|
||||
|
||||
const UP_INDICATOR: &str = "▲";
|
||||
|
||||
/// Unique prompt text so the sticky-header row is unambiguous on screen
|
||||
/// (the shared [`PROMPT`] "go" collides with hint/footer copy).
|
||||
const TOP_PROMPT: &str = "SHOWTOPPROMPT";
|
||||
|
||||
fn long_answer() -> String {
|
||||
(1..=60)
|
||||
.map(|i| format!("- ANSWERLINE{i:03} lorem ipsum dolor sit amet consectetur"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
/// Row index of `needle` on screen, for the artifact trace.
|
||||
fn row_of(screen: &str, needle: &str) -> Option<usize> {
|
||||
screen.lines().position(|line| line.contains(needle))
|
||||
}
|
||||
|
||||
/// Artifact trace: the viewport's top rows, where the sticky header, the ▲,
|
||||
/// and (after the click) the answer's first line all live.
|
||||
fn dump_top_rows(label: &str, screen: &str) {
|
||||
eprintln!("[{label}] top of viewport:");
|
||||
for line in screen.lines().take(10) {
|
||||
eprintln!(" {}", line.trim_end());
|
||||
}
|
||||
}
|
||||
|
||||
/// PTY: stream an answer taller than the terminal, check the ▲ indicator
|
||||
/// appears under the sticky prompt header, click it, and check the viewport
|
||||
/// lands on the answer's first line.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore = "PTY e2e; run the owning pty_e2e_* Cargo test with --ignored (see Cargo.toml)"]
|
||||
async fn response_top_indicator_jumps_to_answer_start() {
|
||||
let content = ContentController::start().await.expect("start content");
|
||||
content.set_response(long_answer());
|
||||
|
||||
let binary = pager_binary().expect("resolve pager binary");
|
||||
let mut harness =
|
||||
PtyHarness::spawn_with_content(&binary, DEFAULT_ROWS, DEFAULT_COLS, &content, &[])
|
||||
.expect("spawn pager with content");
|
||||
|
||||
harness
|
||||
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
|
||||
.expect("welcome text");
|
||||
harness
|
||||
.inject_keys(format!("{TOP_PROMPT}\r").as_bytes())
|
||||
.expect("submit prompt");
|
||||
|
||||
// Follow mode parks the finished turn at the answer's tail; the ▲
|
||||
// renders in the sticky header's gap row as soon as the answer's first
|
||||
// line scrolls off the top.
|
||||
harness
|
||||
.wait_for_text(LAST_LINE, Duration::from_secs(60))
|
||||
.expect("answer tail visible (follow mode at the bottom)");
|
||||
harness
|
||||
.wait_for_text(UP_INDICATOR, Duration::from_secs(10))
|
||||
.expect("▲ indicator rendered under the sticky prompt header");
|
||||
|
||||
let screen = harness.screen_contents();
|
||||
assert!(
|
||||
!screen.contains(FIRST_LINE),
|
||||
"the answer must overflow the viewport for this test to prove \
|
||||
anything\nscreen:\n{screen}"
|
||||
);
|
||||
let (arrow_row, arrow_col) =
|
||||
locate_screen_text(&screen, UP_INDICATOR).expect("locate ▲ on screen");
|
||||
let prompt_row = row_of(&screen, TOP_PROMPT);
|
||||
eprintln!(
|
||||
"[before click] ▲ at ({arrow_row},{arrow_col}), sticky prompt row {prompt_row:?}, \
|
||||
answer tail row {:?}",
|
||||
row_of(&screen, LAST_LINE)
|
||||
);
|
||||
dump_top_rows("before click", &screen);
|
||||
// "Under the last user prompt block": the sticky header pins the prompt
|
||||
// above the gap row that hosts the ▲.
|
||||
let prompt_row = prompt_row.expect("sticky header shows the prompt");
|
||||
assert!(
|
||||
prompt_row < arrow_row as usize && (arrow_row as usize) < prompt_row + 6,
|
||||
"▲ should sit in the gap row just below the pinned prompt\nscreen:\n{screen}"
|
||||
);
|
||||
|
||||
// Click the ▲ (SGR left press + release at its cell).
|
||||
let click = format!(
|
||||
"{}{}",
|
||||
sgr_mouse(0, arrow_row, arrow_col, 'M'),
|
||||
sgr_mouse(0, arrow_row, arrow_col, 'm')
|
||||
);
|
||||
harness.inject_keys(click.as_bytes()).expect("click ▲");
|
||||
|
||||
harness
|
||||
.wait_for_text(FIRST_LINE, Duration::from_secs(10))
|
||||
.expect("answer snaps to its first line after the click");
|
||||
|
||||
let screen = harness.screen_contents();
|
||||
let first_row = row_of(&screen, FIRST_LINE);
|
||||
eprintln!(
|
||||
"[after click] first answer line at row {first_row:?}, last answer line at row {:?}",
|
||||
row_of(&screen, LAST_LINE)
|
||||
);
|
||||
dump_top_rows("after click", &screen);
|
||||
assert!(
|
||||
!screen.contains(LAST_LINE),
|
||||
"after the jump the tail must be off screen again\nscreen:\n{screen}"
|
||||
);
|
||||
let first_row = first_row.expect("first line is on screen");
|
||||
assert!(
|
||||
first_row < usize::from(DEFAULT_ROWS) / 3,
|
||||
"the answer's first line should be parked near the top of the \
|
||||
viewport, found it at row {first_row}\nscreen:\n{screen}"
|
||||
);
|
||||
assert!(
|
||||
!harness.contains_text(UP_INDICATOR),
|
||||
"with the answer's top on screen the ▲ must disappear\nscreen:\n{screen}"
|
||||
);
|
||||
|
||||
harness.quit().expect("clean quit");
|
||||
}
|
||||
|
|
@ -40,6 +40,8 @@ mod mouse_reporting_toggle_sticky_persists_pty;
|
|||
mod nested_quote_drag_copy_excludes_bars_pty;
|
||||
#[path = "pty_e2e/page_flip_on_send_pty.rs"]
|
||||
mod page_flip_on_send_pty;
|
||||
#[path = "pty_e2e/plan_scrollbar_grab_zone_pty.rs"]
|
||||
mod plan_scrollbar_grab_zone_pty;
|
||||
#[path = "pty_e2e/quote_block_drag_copy_excludes_bars_pty.rs"]
|
||||
mod quote_block_drag_copy_excludes_bars_pty;
|
||||
#[path = "pty_e2e/quote_block_raw_mode_copy_keeps_source_pty.rs"]
|
||||
|
|
@ -50,6 +52,8 @@ mod read_tool_header_selection_copies_path_only_pty;
|
|||
mod recap_header_not_in_selection_pty;
|
||||
#[path = "pty_e2e/resize_preserves_scroll_position.rs"]
|
||||
mod resize_preserves_scroll_position;
|
||||
#[path = "pty_e2e/response_top_indicator_pty.rs"]
|
||||
mod response_top_indicator_pty;
|
||||
#[path = "pty_e2e/scroll_debug_hud_env_toggles_overlay.rs"]
|
||||
mod scroll_debug_hud_env_toggles_overlay;
|
||||
#[path = "pty_e2e/scroll_does_not_crash.rs"]
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@ mod initial_prompt_positional_auto_submits;
|
|||
mod input_echoes_at_idle_prompt;
|
||||
#[path = "pty_e2e/plan_revise_empty_enter_does_not_approve.rs"]
|
||||
mod plan_revise_empty_enter_does_not_approve;
|
||||
#[path = "pty_e2e/question_tab_cycles_answers.rs"]
|
||||
mod question_tab_cycles_answers;
|
||||
#[path = "pty_e2e/renders_on_action.rs"]
|
||||
mod renders_on_action;
|
||||
#[path = "pty_e2e/requirements_version_failure_exits_2_with_guidance.rs"]
|
||||
|
|
|
|||
Loading…
Reference in a new issue