Synced from monorepo

Changes:
- Non-blocking coding-data sharing upsell banner
- Consolidate remediation in Doctor
- Auto mode defers fail-closed gate asks to the classifier
- Coalesce marketplace list fetches
- Allow removing a marketplace source by name
- Contain hung git marketplace sources (timeouts, non-blocking refresh, unbrick modal)
- Label failed workspace RPCs with error_kind
- Drop redundant explicit tonic/prost deps from xai-grok-shell
- Report real exit codes for completed background shells
- Narrow the date-rollover reminder to date-bearing templates
- Wire toolOverrides through the session and agent
- Security: Bash(git:*) allowlist matches whole command chain by prefix
- Split prompt-trigger telemetry and record classifier provenance
- Raise connectors-manager timeout to 60s
- Auto classifier honors recorded approvals for repeat actions
- Apply doctor fixes in the TUI
- Auto-mode classifier timeouts prompt instead of silently denying
- Scope subagent completion drains to the owning session
- Add the toolOverrides wire types
- Set client_identifier=grok-agent-sdk
- Accept both spellings of the workspace-teleport kill switch
- Persist one-shot occurrence journal
- Stop turns that poll the exact same tool call 16x in a row
- Copy compaction checkpoint files when forking sessions
- Auto-focus permission prompt from scrollback
- Esc cancels the running turn in non-vim and minimal modes
- List Ctrl+Z undo and redo in keyboard shortcuts
- Out-of-process macOS mic capture
- Show active auth mode on session-info
- Install the npm binary under $GROK_HOME
- Remove hover/click dead zones between dashboard items
- Route startup warnings to doctor
- Document [feedback.user] author identity config
- Extend bang command timeout
- Close combine-queued edit-hold race
- Integrate relocation recovery
- Expose privacy notice rollout flag
- Break harness discovery ref cycle so connections can idle-evict
- Shift/Alt+Enter inserts newline when editing a queued prompt
- Gate project Claude permissions on folder trust
- Echo response.create.event_id on response.created
- Toast when session creation fails from disk full
- Add shared test process lifecycle
- Enable dynamic workflows by default
- Add relocation transaction state machine
- Add shared test sandbox
- Surface auth failures on model-switch compact
- Persist durable scheduler expiry
- Confirm before removing extensions-modal items
- Re-run compact and prompt after login when compact hit expired auth
- Recap sends hosted tools under backend search
This commit is contained in:
grokkybara[bot] 2026-07-22 19:18:53 +01:00
commit a5727c5960
482 changed files with 37627 additions and 13402 deletions

View file

@ -58,7 +58,7 @@ async fn agent_type_mismatch_no_keeps_current_session() {
harness.screen_contents()
);
assert!(
harness.is_running(),
harness.is_running().expect("poll pager liveness"),
"pager exited\nscreen:\n{}",
harness.screen_contents()
);

View file

@ -49,7 +49,7 @@ async fn agent_type_mismatch_yes_starts_new_session() {
.expect("new session created");
assert!(
harness.is_running(),
harness.is_running().expect("poll pager liveness"),
"pager exited after starting new session\nscreen:\n{}",
harness.screen_contents()
);

View file

@ -72,7 +72,7 @@ async fn auto_compact_top_row() {
.expect("resize short");
harness.update(Duration::from_millis(900));
assert!(
harness.is_running(),
harness.is_running().expect("poll pager liveness"),
"pager exited during resize\nscreen:\n{}",
harness.screen_contents()
);

View file

@ -103,8 +103,13 @@ async fn background_task_reaped_on_quit() {
// Both the graceful-quit teardown and the hard-exit tail reap spawned
// children via the process-global ProcessScope, so the orphan dies either way.
harness.send_signal(libc::SIGINT).expect("send SIGINT");
let code = harness.wait_exit_code(Duration::from_secs(15));
assert!(code.is_some(), "pager did not exit after SIGINT");
let exit = harness
.wait_exit_code(Duration::from_secs(15))
.expect("wait after SIGINT");
assert!(
matches!(exit, PtyExitPoll::Exited(_) | PtyExitPoll::PendingStatus),
"pager did not exit after SIGINT: {exit:?}"
);
// The fix: no orphaned background process survives the quit. Without it the
// setsid-detached sleep reparents to init and keeps running -> this times out.

View file

@ -14,18 +14,18 @@ const INNER_SENTINEL: &str = "INNER-NOTE-SENTINEL-4173";
/// history tier is pinned to a nonexistent file so file completions are the
/// ONLY dropdown source.
fn suggestions_env(content: &ContentController) -> Vec<(String, String)> {
let mut env = content.env_for_pager();
env.push(("SHELL".into(), "/bin/bash".into()));
env.push(("GROK_SUGGESTIONS".into(), "0".into()));
env.push((
"HISTFILE".into(),
content
.home()
.join(".no_such_history")
.to_string_lossy()
.into_owned(),
));
env
vec![
("SHELL".into(), "/bin/bash".into()),
("GROK_SUGGESTIONS".into(), "0".into()),
(
"HISTFILE".into(),
content
.home()
.join(".no_such_history")
.to_string_lossy()
.into_owned(),
),
]
}
/// Seed the session cwd the file provider lists:
@ -75,12 +75,16 @@ async fn bash_mode_file_completion_shell_like() {
content.set_response(format!("{MOCK_RESPONSE_SENTINEL} session up."));
let env = suggestions_env(&content);
let env_refs: Vec<(&str, &str)> = env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
let env_refs: Vec<(&str, &str)> = env
.iter()
.map(|(key, value)| (key.as_str(), value.as_str()))
.collect();
let binary = pager_binary().expect("resolve pager binary");
let mut harness = PtyHarness::new_in_dir(
let mut harness = PtyHarness::spawn_with_content_env_in_dir(
&binary,
DEFAULT_ROWS,
DEFAULT_COLS,
&content,
&[],
&env_refs,
Some(&cwd),

View file

@ -22,12 +22,12 @@ const TYPED_PREFIX: &str = "!cat SUGGEST";
/// as-you-type pipeline OFF hermetically (the PTY child inherits the parent
/// env, so a dev shell exporting the flag must not turn it on here); the
/// shell-history tier is pinned to the seeded file.
fn suggestions_env(content: &ContentController, histfile: &Path) -> Vec<(String, String)> {
let mut env = content.env_for_pager();
env.push(("SHELL".into(), "/bin/bash".into()));
env.push(("GROK_SUGGESTIONS".into(), "0".into()));
env.push(("HISTFILE".into(), histfile.to_string_lossy().into_owned()));
env
fn suggestions_env(histfile: &Path) -> Vec<(String, String)> {
vec![
("SHELL".into(), "/bin/bash".into()),
("GROK_SUGGESTIONS".into(), "0".into()),
("HISTFILE".into(), histfile.to_string_lossy().into_owned()),
]
}
fn seed_history(content: &ContentController) -> std::path::PathBuf {
@ -63,13 +63,17 @@ async fn bash_mode_tab_accepts_dropdown_item_in_place() {
content.set_response(format!("{MOCK_RESPONSE_SENTINEL} session up."));
let histfile = seed_history(&content);
let env = suggestions_env(&content, &histfile);
let env_refs: Vec<(&str, &str)> = env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
let env = suggestions_env(&histfile);
let env_refs: Vec<(&str, &str)> = env
.iter()
.map(|(key, value)| (key.as_str(), value.as_str()))
.collect();
let binary = pager_binary().expect("resolve pager binary");
let mut harness = PtyHarness::new_in_dir(
let mut harness = PtyHarness::spawn_with_content_env_in_dir(
&binary,
DEFAULT_ROWS,
DEFAULT_COLS,
&content,
&[],
&env_refs,
Some(&cwd),

View file

@ -49,26 +49,34 @@ async fn bracketed_ime_paste_skips_clipboard_image_linux() {
bin_dir.display(),
std::env::var("PATH").unwrap_or_default()
);
let base_env: Vec<(String, String)> = {
let mut env = content.env_for_pager();
env.push(("PATH".into(), path_env));
env.push(("WAYLAND_DISPLAY".into(), "wayland-fake".into()));
env.push(("DISPLAY".into(), String::new()));
env
};
let base_env = [
("PATH", path_env.as_str()),
("WAYLAND_DISPLAY", "wayland-fake"),
("DISPLAY", ""),
];
/// Spawn the pager with `extra_env` and drive it to the dashboard, where
/// bracketed paste routes to the dispatch input.
fn spawn_on_dashboard(base_env: &[(String, String)], extra_env: &[(&str, &str)]) -> PtyHarness {
let mut env_refs: Vec<(&str, &str)> = base_env
fn spawn_on_dashboard(
content: &ContentController,
base_env: &[(&str, &str)],
extra_env: &[EnvOp<'_>],
) -> PtyHarness {
let mut operations: Vec<_> = base_env
.iter()
.map(|(k, v)| (k.as_str(), v.as_str()))
.map(|(key, value)| EnvOp::set(key, value))
.collect();
env_refs.extend_from_slice(extra_env);
operations.extend_from_slice(extra_env);
let binary = pager_binary().expect("resolve pager binary");
let mut harness =
PtyHarness::new_in_dir(&binary, DEFAULT_ROWS, DEFAULT_COLS, &[], &env_refs, None)
.expect("spawn pager");
let mut harness = PtyHarness::spawn_with_content_env_ops(
&binary,
DEFAULT_ROWS,
DEFAULT_COLS,
content,
&[],
&operations,
)
.expect("spawn pager");
harness
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
.expect("welcome text");
@ -88,7 +96,8 @@ async fn bracketed_ime_paste_skips_clipboard_image_linux() {
}
// ── Otty: IME-style bracketed paste, image-only clipboard → no image ──
let mut harness = spawn_on_dashboard(&base_env, &[("TERM_PROGRAM", "otty")]);
let mut harness =
spawn_on_dashboard(&content, &base_env, &[EnvOp::set("TERM_PROGRAM", "otty")]);
harness
.inject_keys(format!("\x1b[200~{IME_PAYLOAD}\x1b[201~").as_bytes())
.expect("bracketed IME payload");
@ -123,7 +132,7 @@ async fn bracketed_ime_paste_skips_clipboard_image_linux() {
// ── No TERM_PROGRAM (any other terminal): historical behavior intact —
// the same mismatched bracketed payload still attaches the image ──
std::fs::write(&text_file, b"").expect("reset clipboard text");
let mut harness = spawn_on_dashboard(&base_env, &[]);
let mut harness = spawn_on_dashboard(&content, &base_env, &[]);
harness
.inject_keys(format!("\x1b[200~{IME_PAYLOAD}\x1b[201~").as_bytes())
.expect("bracketed payload without otty");

View file

@ -41,12 +41,15 @@ async fn bracketed_ime_paste_skips_clipboard_image_macos() {
let content = ContentController::start().await.expect("start content");
let binary = pager_binary().expect("resolve pager binary");
// The payload-origin gate only runs under Otty (TERM_PROGRAM=otty).
let mut env = content.env_for_pager();
env.push(("TERM_PROGRAM".into(), "otty".into()));
let env_refs: Vec<(&str, &str)> = env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
let mut harness =
PtyHarness::new_in_dir(&binary, DEFAULT_ROWS, DEFAULT_COLS, &[], &env_refs, None)
.expect("spawn pager");
let mut harness = PtyHarness::spawn_with_content_env_ops(
&binary,
DEFAULT_ROWS,
DEFAULT_COLS,
&content,
&[],
&[EnvOp::set("TERM_PROGRAM", "otty")],
)
.expect("spawn pager");
harness
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)

View file

@ -44,11 +44,20 @@ async fn campaign_nudges_default_until_dismissed_by_model_pick() {
let binary = pager_binary().expect("resolve pager binary");
let spawn = |extra: &(String, String)| -> PtyHarness {
let mut env = content.env_for_pager();
env.push(extra.clone());
let env_refs: Vec<(&str, &str)> =
env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
PtyHarness::new(&binary, DEFAULT_ROWS, DEFAULT_COLS, &[], &env_refs).expect("spawn pager")
let overrides: Vec<(String, String)> = vec![extra.clone()];
let env_refs: Vec<(&str, &str)> = overrides
.iter()
.map(|(key, value)| (key.as_str(), value.as_str()))
.collect();
PtyHarness::spawn_with_content_env(
&binary,
DEFAULT_ROWS,
DEFAULT_COLS,
&content,
&[],
&env_refs,
)
.expect("spawn pager")
};
// ── Phase 1: a fresh boot shows the campaign model, not the config one. ──

View file

@ -53,11 +53,16 @@ async fn campaign_remote_settings_nudge_and_dismiss() {
// structurally unreachable (see `spawn_polling_session`'s doc).
seed_fake_oauth(&content, "pty-campaign-remote");
let binary = pager_binary().expect("resolve pager binary");
let env = oauth_env_for_pager(&content);
let spawn = || -> PtyHarness {
let env_refs: Vec<(&str, &str)> =
env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
PtyHarness::new(&binary, DEFAULT_ROWS, DEFAULT_COLS, &[], &env_refs).expect("spawn pager")
PtyHarness::spawn_with_content_env_ops(
&binary,
DEFAULT_ROWS,
DEFAULT_COLS,
&content,
&[],
&oauth_credential_ops(),
)
.expect("spawn pager")
};
// ── Phase 1+2: the campaign applies to a new session; a pick dismisses. ──

View file

@ -6,9 +6,9 @@ pub(crate) use serde_json::json;
pub(crate) use std::path::Path;
pub(crate) use std::time::{Duration, Instant};
pub(crate) use xai_grok_pager_pty_harness::{
AgentTurnExpectation, ContentController, MockModel, PtyHarness, ScriptedResponse, SseEvent,
keys, oauth_env_for_pager, pager_binary, seed_fake_oauth, sse, wait_for_labels_absent,
wait_for_model_via_new_sessions,
AgentTurnExpectation, ContentController, EnvOp, MockModel, PtyExitPoll, PtyHarness,
ScriptedResponse, SseEvent, keys, oauth_credential_ops, pager_binary, seed_fake_oauth, sse,
wait_for_labels_absent, wait_for_model_via_new_sessions,
};
/// Default PTY size used by every e2e test. Large enough to render the
@ -72,13 +72,8 @@ pub(crate) fn wipe_substantial_draft(harness: &mut PtyHarness) {
harness.inject_keys(b"\x15").expect("Ctrl+U kill-to-BOL");
}
/// Content env plus the contextual-hints opt-in. The feature ships default-OFF,
/// so the undo tip (a contextual hint) only fires when explicitly enabled.
pub(crate) fn contextual_hints_env(content: &ContentController) -> Vec<(String, String)> {
let mut env = content.env_for_pager();
env.push(("GROK_CONTEXTUAL_HINTS".into(), "1".into()));
env
}
/// Contextual-hints opt-in. The feature ships default-OFF.
pub(crate) const CONTEXTUAL_HINTS_ENV: &[(&str, &str)] = &[("GROK_CONTEXTUAL_HINTS", "1")];
/// Collect short OSC 8 payloads for assertion failure messages.
pub(crate) fn osc8_snippets(raw: &str) -> String {
@ -143,7 +138,7 @@ pub(crate) fn tall_response(sentinel: &str, rows: usize) -> String {
}
// ── Fake session-auth (OAuth) seeding ───────────────────────────────────
// `seed_fake_oauth` / `oauth_env_for_pager` live in
// `seed_fake_oauth` / `oauth_credential_ops` live in
// `xai_grok_pager_pty_harness::flows` (re-exported above).
/// Spawn a pager with fake session (OAuth) auth and a 1s announcements poll,
@ -165,19 +160,18 @@ pub(crate) fn spawn_polling_session_with_env(
extra_env: &[(&str, &str)],
) -> PtyHarness {
seed_fake_oauth(content, oauth_user);
let env = oauth_env_for_pager(content);
let mut env_refs: Vec<(&str, &str)> =
env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
env_refs.push(("GROK_ANNOUNCEMENTS_REFRESH_INTERVAL_SECS", "1"));
env_refs.extend_from_slice(extra_env);
let mut overrides = Vec::from(oauth_credential_ops());
overrides.push(EnvOp::set("GROK_ANNOUNCEMENTS_REFRESH_INTERVAL_SECS", "1"));
overrides.extend(extra_env.iter().map(|(key, value)| EnvOp::set(key, value)));
let binary = pager_binary().expect("resolve pager binary");
let mut harness = PtyHarness::new_in_dir(
let mut harness = PtyHarness::spawn_with_content_env_ops_in_dir(
&binary,
DEFAULT_ROWS,
DEFAULT_COLS,
content,
&[],
&env_refs,
&overrides,
Some(content.home()),
)
.expect("spawn pager with polling session auth");
@ -224,22 +218,13 @@ pub(crate) fn git_repo_with_mcp_json() -> tempfile::TempDir {
repo
}
/// Env for a folder-trust run: the mock-server env plus a simulated release stamp
/// (`GROK_TEST_VERSION`) and an explicit `GROK_FOLDER_TRUST` — `1` when `feature_on`,
/// else `0` (an explicit opt-out that overrides the now-on default). HOME/GROK_HOME
/// point at the isolated temp home, so the trust store starts empty.
pub(crate) fn trust_env(content: &ContentController, feature_on: bool) -> Vec<(String, String)> {
let mut env = content.env_for_pager();
// A self-built (unstamped) grok auto-trusts and never prompts; simulate a
// release build so the folder-trust feature is actually evaluated here. The
// feature-off case below then exercises the TRUE feature-off path, not
// auto-trust.
env.push(("GROK_TEST_VERSION".into(), "0.0.0-sim".into()));
// Set GROK_FOLDER_TRUST explicitly: the default is on, so `0` is the opt-out
// that exercises the feature-off path rather than relying on an absent var.
let folder_trust = if feature_on { "1" } else { "0" };
env.push(("GROK_FOLDER_TRUST".into(), folder_trust.into()));
env
/// Explicit overrides for a folder-trust run. A self-built grok auto-trusts,
/// so `GROK_TEST_VERSION` simulates a release; the gate is pinned both ways.
pub(crate) fn trust_env(feature_on: bool) -> [(&'static str, &'static str); 2] {
[
("GROK_TEST_VERSION", "0.0.0-sim"),
("GROK_FOLDER_TRUST", if feature_on { "1" } else { "0" }),
]
}
/// Whether the isolated trust store has recorded a grant for `repo`'s workspace.
@ -451,20 +436,18 @@ pub(crate) fn seed_keep_text_selection_config(content: &ContentController) {
.expect("write config.toml");
}
/// Content env plus opt-in enablement env (belt-and-suspenders with config seed).
pub(crate) fn mouse_toggle_env(content: &ContentController) -> Vec<(String, String)> {
let mut env = content.env_for_pager();
env.push(("GROK_MOUSE_REPORTING_TOGGLE".into(), "true".into()));
env
}
/// Spawn pager with content + mouse-toggle env (same base as `spawn_with_content`,
/// but forwards the extra enablement env that `spawn_with_content` alone omits).
/// Spawn pager with the mouse-toggle opt-in after the sandbox baseline.
pub(crate) fn spawn_mouse_toggle_pager(content: &ContentController) -> PtyHarness {
let binary = pager_binary().expect("resolve pager binary");
let env = mouse_toggle_env(content);
let env_refs: Vec<(&str, &str)> = env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
PtyHarness::new(&binary, DEFAULT_ROWS, DEFAULT_COLS, &[], &env_refs).expect("spawn pager")
PtyHarness::spawn_with_content_env_ops(
&binary,
DEFAULT_ROWS,
DEFAULT_COLS,
content,
&[],
&[EnvOp::set("GROK_MOUSE_REPORTING_TOGGLE", "true")],
)
.expect("spawn pager")
}
/// Inject keys one byte at a time with a short drain between each so the pager
@ -487,13 +470,16 @@ pub(crate) const ESC_DOUBLE_PRESS_ENV: &str = "GROK_ESC_DOUBLE_PRESS_MS";
/// Spawn the pager with [`ESC_DOUBLE_PRESS_ENV`] set to the 60s cap.
pub(crate) fn spawn_esc_double_press_pager(content: &ContentController) -> PtyHarness {
let binary = pager_binary().expect("resolve pager binary");
let mut env = content.env_for_pager();
env.push((
ESC_DOUBLE_PRESS_ENV.to_string(),
xai_grok_pager::app::app_view::ESC_DOUBLE_PRESS_TEST_MS.to_string(),
));
let env_refs: Vec<(&str, &str)> = env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
PtyHarness::new(&binary, DEFAULT_ROWS, DEFAULT_COLS, &[], &env_refs).expect("spawn pager")
let value = xai_grok_pager::app::app_view::ESC_DOUBLE_PRESS_TEST_MS.to_string();
PtyHarness::spawn_with_content_env_ops(
&binary,
DEFAULT_ROWS,
DEFAULT_COLS,
content,
&[],
&[EnvOp::set(ESC_DOUBLE_PRESS_ENV, value.as_str())],
)
.expect("spawn pager")
}
/// Reach an agent session with scrollback content, then focus scrollback (Tab).
@ -512,8 +498,8 @@ pub(crate) async fn drive_to_scrollback_with_turn(
.wait_for_text(MOCK_RESPONSE_SENTINEL, Duration::from_secs(30))
.expect("turn rendered");
// Leave the prompt so scrollback-only Ctrl+R can fire (unbound on the prompt).
// Tab is the leave-prompt / focus-scrollback key (Esc is clear/rewind idle /
// mid-turn swallow).
// Tab is the leave-prompt / focus-scrollback key (Esc is reserved for the
// cancel / clear / rewind policy).
harness.inject_keys(b"\t").expect("focus scrollback (tab)");
harness.update(Duration::from_millis(500));
// Footer shows "Space:prompt" when scrollback owns keys (prompt is not focused).
@ -995,8 +981,48 @@ pub(crate) fn quit_minimal(harness: &mut PtyHarness) {
let _ = harness.inject_keys(b"\x11"); // Ctrl+Q — arms the confirm
harness.update(Duration::from_millis(80));
let _ = harness.inject_keys(b"\x11"); // Ctrl+Q — confirms
if harness.wait_exit_code(Duration::from_secs(5)).is_none() {
let _ = harness.quit(); // kill fallback
match harness
.wait_exit_code(Duration::from_secs(5))
.expect("wait for minimal pager exit")
{
PtyExitPoll::Running => harness.quit().expect("kill minimal pager after timeout"),
PtyExitPoll::Exited(_) | PtyExitPoll::PendingStatus => {}
}
}
const EXIT_STATUS_POLL_INTERVAL: Duration = Duration::from_millis(50);
fn resolve_exit_status_poll<T, E>(
poll: Result<PtyExitPoll<T>, E>,
deadline_reached: bool,
) -> Result<Option<PtyExitPoll<T>>, E> {
match poll? {
PtyExitPoll::Exited(code) => Ok(Some(PtyExitPoll::Exited(code))),
state if deadline_reached => Ok(Some(state)),
PtyExitPoll::Running | PtyExitPoll::PendingStatus => Ok(None),
}
}
/// Wait for a concrete exit status while preserving the typed deadline state.
pub(crate) fn wait_for_exit_status(
harness: &mut PtyHarness,
timeout: Duration,
) -> anyhow::Result<PtyExitPoll<u32>> {
let deadline = Instant::now() + timeout;
loop {
if let Some(state) = resolve_exit_status_poll(
harness.wait_exit_code(Duration::ZERO),
Instant::now() >= deadline,
)? {
return Ok(state);
}
let remaining = deadline.saturating_duration_since(Instant::now());
harness.update(EXIT_STATUS_POLL_INTERVAL.min(remaining));
let sleep_for =
Duration::from_millis(10).min(deadline.saturating_duration_since(Instant::now()));
if !sleep_for.is_zero() {
std::thread::sleep(sleep_for);
}
}
}
@ -1012,7 +1038,7 @@ pub(crate) const WRAP_TIMEOUT: Duration = Duration::from_secs(120);
const WRAP_DRAIN_TIMEOUT: Duration = Duration::from_secs(10);
/// Run `grok wrap <wrap_args...>` to completion inside a PTY with an isolated
/// `GROK_HOME`, returning the exit code (`None` if it never exited within
/// `GROK_HOME`, returning the exit code (`None` only while still running at
/// [`WRAP_TIMEOUT`]) and everything the wrap PTY emitted. `extra_env` is where
/// tests pin `SHELL`; wrap needs no mock content — it dispatches in `main`
/// before auth/network/sandbox.
@ -1040,16 +1066,25 @@ pub(crate) fn run_wrap_driving(
env.extend_from_slice(extra_env);
let mut harness =
PtyHarness::new(&binary, DEFAULT_ROWS, DEFAULT_COLS, &args, &env).expect("spawn grok wrap");
PtyHarness::new_inherited_env(&binary, DEFAULT_ROWS, DEFAULT_COLS, &args, &env, None)
.expect("spawn grok wrap");
drive(&mut harness);
let code = harness
.wait_for_exit_and_drain(WRAP_TIMEOUT, WRAP_DRAIN_TIMEOUT)
.ok();
if code.is_none() {
let _ = harness.quit(); // kill a hung child so the suite doesn't leak it
}
let code = match wait_for_exit_status(&mut harness, WRAP_TIMEOUT) {
Ok(PtyExitPoll::Exited(code)) => {
harness.update(WRAP_DRAIN_TIMEOUT);
Some(code)
}
Ok(PtyExitPoll::Running) => {
harness.quit().expect("kill grok wrap after timeout");
None
}
Ok(PtyExitPoll::PendingStatus) => {
panic!("grok wrap exited but portable status remained unavailable for {WRAP_TIMEOUT:?}")
}
Err(error) => panic!("poll grok wrap exit: {error:#}"),
};
let raw = String::from_utf8_lossy(harness.raw_output()).into_owned();
(code, raw)
@ -1196,3 +1231,36 @@ pub(crate) use xai_grok_pager_pty_harness::host_clipboard::{
// this and SKIP instead of failing on environment.
#[cfg(target_os = "windows")]
pub(crate) use xai_grok_pager_pty_harness::host_clipboard::clipboard_roundtrip_works;
#[cfg(test)]
mod exit_status_wait_policy_tests {
use super::*;
#[test]
fn waits_for_running_and_pending_until_deadline_and_propagates_errors() {
assert_eq!(
resolve_exit_status_poll::<u32, &'static str>(Ok(PtyExitPoll::Exited(2)), false),
Ok(Some(PtyExitPoll::Exited(2)))
);
assert_eq!(
resolve_exit_status_poll::<u32, &'static str>(Ok(PtyExitPoll::Running), false),
Ok(None)
);
assert_eq!(
resolve_exit_status_poll::<u32, &'static str>(Ok(PtyExitPoll::PendingStatus), false),
Ok(None)
);
assert_eq!(
resolve_exit_status_poll::<u32, &'static str>(Ok(PtyExitPoll::Running), true),
Ok(Some(PtyExitPoll::Running))
);
assert_eq!(
resolve_exit_status_poll::<u32, &'static str>(Ok(PtyExitPoll::PendingStatus), true),
Ok(Some(PtyExitPoll::PendingStatus))
);
assert_eq!(
resolve_exit_status_poll::<u32, &'static str>(Err("poll failed"), true),
Err("poll failed")
);
}
}

View file

@ -51,16 +51,19 @@ fn info_override_json() -> String {
fn spawn_with_announcements(content: &ContentController, override_json: &str) -> PtyHarness {
let binary = pager_binary().expect("resolve pager binary");
let mut env = content.env_for_pager();
env.push((
let overrides: Vec<(String, String)> = vec![(
"GROK_ANNOUNCEMENTS_OVERRIDE".into(),
override_json.to_owned(),
));
let env_refs: Vec<(&str, &str)> = env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
PtyHarness::new_in_dir(
)];
let env_refs: Vec<(&str, &str)> = overrides
.iter()
.map(|(key, value)| (key.as_str(), value.as_str()))
.collect();
PtyHarness::spawn_with_content_env_in_dir(
&binary,
DEFAULT_ROWS,
DEFAULT_COLS,
content,
&[],
&env_refs,
Some(content.home()),
@ -883,18 +886,14 @@ fn spawn_with_announcements_and_env(
extra_env: &[(&str, &str)],
) -> PtyHarness {
let binary = pager_binary().expect("resolve pager binary");
let mut env = content.env_for_pager();
env.push((
"GROK_ANNOUNCEMENTS_OVERRIDE".into(),
override_json.to_owned(),
));
let mut env_refs: Vec<(&str, &str)> =
env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
let announcement = ("GROK_ANNOUNCEMENTS_OVERRIDE", override_json);
let mut env_refs = vec![announcement];
env_refs.extend_from_slice(extra_env);
PtyHarness::new_in_dir(
PtyHarness::spawn_with_content_env_in_dir(
&binary,
DEFAULT_ROWS,
DEFAULT_COLS,
content,
&[],
&env_refs,
Some(content.home()),

View file

@ -9,7 +9,7 @@ use super::common::*;
/// and the `prompt_complete` broadcast (which arms the lost-response
/// reconcile), and a double-finish would render two markers — and (b) leave
/// the pane usable: no `TurnCancelling` latch, the next typed prompt runs.
/// Cancel is via Ctrl+C (Esc no longer cancels mid-turn).
/// Cancel is via Ctrl+C, which works in every mode.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore]
async fn ctrl_c_cancel_during_stream_recovers_cleanly() {

View file

@ -30,13 +30,23 @@ async fn out_of_band_stale_row_heals_on_focus_gained() {
// Mock-auth env + pretend we're inside a neovim `:terminal` (sets the
// embedded-editor context the doubled-line fix gates on).
let mut env = content.env_for_pager();
env.push(("NVIM".into(), "/tmp/grok-pty-harness-fake-nvim.sock".into()));
let env_refs: Vec<(&str, &str)> = env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
let overrides: Vec<(String, String)> =
vec![("NVIM".into(), "/tmp/grok-pty-harness-fake-nvim.sock".into())];
let env_refs: Vec<(&str, &str)> = overrides
.iter()
.map(|(key, value)| (key.as_str(), value.as_str()))
.collect();
let binary = pager_binary().expect("resolve pager binary");
let mut h =
PtyHarness::new(&binary, DEFAULT_ROWS, DEFAULT_COLS, &[], &env_refs).expect("spawn pager");
let mut h = PtyHarness::spawn_with_content_env(
&binary,
DEFAULT_ROWS,
DEFAULT_COLS,
&content,
&[],
&env_refs,
)
.expect("spawn pager");
h.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
.expect("welcome screen");

View file

@ -22,16 +22,19 @@ async fn drag_enters_content_from_gap_pty() {
content.set_response(GAPDEEP_LINE.to_string());
let binary = pager_binary().expect("resolve pager binary");
let mut env = content.env_for_pager();
env.push((
let overrides: Vec<(String, String)> = vec![(
"SSH_CONNECTION".into(),
"scripted-test 1 127.0.0.1 2".into(),
));
let env_refs: Vec<(&str, &str)> = env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
let mut harness = PtyHarness::new_in_dir(
)];
let env_refs: Vec<(&str, &str)> = overrides
.iter()
.map(|(key, value)| (key.as_str(), value.as_str()))
.collect();
let mut harness = PtyHarness::spawn_with_content_env_in_dir(
&binary,
DEFAULT_ROWS,
DEFAULT_COLS,
&content,
&[],
&env_refs,
Some(content.home()),

View file

@ -23,16 +23,19 @@ async fn drag_from_above_prompt_strip_pty() {
content.set_response(STRIPDEEP_LINE.to_string());
let binary = pager_binary().expect("resolve pager binary");
let mut env = content.env_for_pager();
env.push((
let overrides: Vec<(String, String)> = vec![(
"SSH_CONNECTION".into(),
"scripted-test 1 127.0.0.1 2".into(),
));
let env_refs: Vec<(&str, &str)> = env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
let mut harness = PtyHarness::new_in_dir(
)];
let env_refs: Vec<(&str, &str)> = overrides
.iter()
.map(|(key, value)| (key.as_str(), value.as_str()))
.collect();
let mut harness = PtyHarness::spawn_with_content_env_in_dir(
&binary,
DEFAULT_ROWS,
DEFAULT_COLS,
&content,
&[],
&env_refs,
Some(content.home()),

View file

@ -23,16 +23,19 @@ async fn drag_from_chrome_stays_block_pty() {
));
let binary = pager_binary().expect("resolve pager binary");
let mut env = content.env_for_pager();
env.push((
let overrides: Vec<(String, String)> = vec![(
"SSH_CONNECTION".into(),
"scripted-test 1 127.0.0.1 2".into(),
));
let env_refs: Vec<(&str, &str)> = env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
let mut harness = PtyHarness::new_in_dir(
)];
let env_refs: Vec<(&str, &str)> = overrides
.iter()
.map(|(key, value)| (key.as_str(), value.as_str()))
.collect();
let mut harness = PtyHarness::spawn_with_content_env_in_dir(
&binary,
DEFAULT_ROWS,
DEFAULT_COLS,
&content,
&[],
&env_refs,
Some(content.home()),

View file

@ -24,16 +24,19 @@ async fn drag_over_gap_rows_does_not_freeze_head_pty() {
));
let binary = pager_binary().expect("resolve pager binary");
let mut env = content.env_for_pager();
env.push((
let overrides: Vec<(String, String)> = vec![(
"SSH_CONNECTION".into(),
"scripted-test 1 127.0.0.1 2".into(),
));
let env_refs: Vec<(&str, &str)> = env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
let mut harness = PtyHarness::new_in_dir(
)];
let env_refs: Vec<(&str, &str)> = overrides
.iter()
.map(|(key, value)| (key.as_str(), value.as_str()))
.collect();
let mut harness = PtyHarness::spawn_with_content_env_in_dir(
&binary,
DEFAULT_ROWS,
DEFAULT_COLS,
&content,
&[],
&env_refs,
Some(content.home()),

View file

@ -37,16 +37,19 @@ async fn drag_select_autoscroll_full_scrollout_copy_pty() {
);
let binary = pager_binary().expect("resolve pager binary");
let mut env = content.env_for_pager();
env.push((
let overrides: Vec<(String, String)> = vec![(
"SSH_CONNECTION".into(),
"scripted-test 1 127.0.0.1 2".into(),
));
let env_refs: Vec<(&str, &str)> = env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
let mut harness = PtyHarness::new_in_dir(
)];
let env_refs: Vec<(&str, &str)> = overrides
.iter()
.map(|(key, value)| (key.as_str(), value.as_str()))
.collect();
let mut harness = PtyHarness::spawn_with_content_env_in_dir(
&binary,
DEFAULT_ROWS,
DEFAULT_COLS,
&content,
&[],
&env_refs,
Some(content.home()),

View file

@ -2,12 +2,15 @@
#[allow(unused_imports)]
use super::common::*;
/// Mid-turn Esc from the PROMPT pane is a swallowed no-op: it must NOT cancel
/// the turn and must NOT arm idle clear/rewind, even with a non-empty draft.
/// Draft text stays in the composer; cancel remains on Ctrl+C / palette / etc.
/// **1× Esc from the PROMPT pane cancels a running turn even with a non-empty
/// draft, and the draft is PRESERVED** (unlike Ctrl+C, which clears the draft
/// first). The harness spawns with the default (non-vim) config, so the
/// Esc-cancel gate is on. Proves the real binary routes a bare Esc through
/// `try_handle_esc_policy`'s turn-running branch before the idle clear/rewind
/// branches, and that cancel does not wipe the composer.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore]
async fn esc_mid_turn_from_prompt_is_swallowed_preserves_draft() {
async fn esc_cancels_running_turn_from_prompt_preserves_draft() {
let content = ContentController::start().await.expect("start content");
// Long paced stream so the turn is still visibly running when Esc lands.
let long_response = format!(
@ -41,41 +44,27 @@ async fn esc_mid_turn_from_prompt_is_swallowed_preserves_draft() {
.wait_for_text(draft, Duration::from_secs(10))
.expect("draft renders in the composer");
// 1× Esc mid-turn must swallow (not cancel, not arm clear).
// 1× Esc cancels immediately (turn-running branch wins over idle clear).
harness.inject_keys(keys::ESC).expect("press esc");
harness.update(Duration::from_millis(1000));
let screen = harness.screen_contents();
harness.update(Duration::from_millis(200));
assert!(
!screen.contains("Turn cancelled by user"),
"mid-turn Esc must NOT cancel the turn\nscreen:\n{screen}"
);
assert!(
screen.contains(draft),
"mid-turn Esc must preserve the draft\nscreen:\n{screen}"
);
assert!(
!screen.contains("press again to clear"),
"running-turn Esc must not arm the idle clear\nscreen:\n{screen}"
);
// 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 from this pane. With a
// non-empty draft the first Ctrl+C clears the draft and keeps the turn;
// the second (now on an empty prompt) cancels it.
harness.inject_keys(keys::CTRL_C).expect("first ctrl+c");
wait_for_labels_absent(&mut harness, &[draft], Duration::from_secs(10));
assert!(
!harness.contains_text(draft),
"first Ctrl+C must clear the draft, not cancel\nscreen:\n{}",
harness.screen_contents()
);
harness.inject_keys(keys::CTRL_C).expect("second ctrl+c");
harness
.wait_for_text("Turn cancelled by user", Duration::from_secs(15))
.expect("Ctrl+C on the empty prompt must cancel the still-running turn");
.expect("turn cancelled marker");
harness.update(Duration::from_millis(600));
let screen = harness.screen_contents();
// The draft must survive the cancel — Esc cancels, it does not clear.
assert!(
screen.contains(draft),
"Esc cancel must preserve the draft (not clear it like Ctrl+C)\nscreen:\n{screen}"
);
// No double-press confirm leaked into the bar — single Esc was enough.
assert!(
!screen.contains("press again to clear"),
"running-turn Esc must cancel, never arm the idle clear\nscreen:\n{screen}"
);
assert!(
!harness.contains_text("panicked"),
"pager panicked\nscreen:\n{}",

View file

@ -2,11 +2,15 @@
#[allow(unused_imports)]
use super::common::*;
/// Mid-turn Esc from the SCROLLBACK pane is a swallowed no-op: it must NOT
/// cancel the running turn. Cancel remains on Ctrl+C / palette / etc.
/// **1× Esc from the SCROLLBACK pane cancels a running turn** in the default
/// (non-vim) config. The policy treats Prompt and Scrollback identically while
/// a turn runs, so a user reading the transcript can interrupt without first
/// returning to the prompt. Tab (not Esc) is used to leave the prompt; the
/// footer's "Space:prompt" hint confirms the scrollback owns keys before the
/// cancel Esc is sent.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore]
async fn esc_mid_turn_from_scrollback_is_swallowed() {
async fn esc_cancels_running_turn_from_scrollback() {
let content = ContentController::start().await.expect("start content");
let long_response = format!(
"{MOCK_RESPONSE_SENTINEL} {}",
@ -31,31 +35,30 @@ async fn esc_mid_turn_from_scrollback_is_swallowed() {
.wait_for_text(MOCK_RESPONSE_SENTINEL, Duration::from_secs(30))
.expect("stream started");
// Leave the prompt with a SINGLE Tab, then wait for the footer to prove the
// scrollback owns keys. Tab TOGGLES focus, so re-pressing it could bounce
// focus back to the prompt — press once and poll the render instead.
// Leave the prompt with a SINGLE Tab (Esc is reserved for cancel/clear/
// rewind), then wait for the footer to prove the scrollback owns keys. Tab
// TOGGLES focus, so re-pressing it could bounce focus back to the prompt —
// press once and poll the render instead (mirrors `drive_to_scrollback_with_turn`).
harness.inject_keys(b"\t").expect("tab to scrollback");
harness
.wait_for_text("Space:prompt", Duration::from_secs(10))
.expect("scrollback must own keys before the mid-turn Esc");
.expect("scrollback must own keys before the cancel Esc");
// 1× Esc from scrollback must swallow (not cancel).
// 1× Esc from scrollback cancels the running turn.
harness.inject_keys(keys::ESC).expect("press esc");
harness.update(Duration::from_millis(1000));
let screen = harness.screen_contents();
assert!(
!screen.contains("Turn cancelled by user"),
"mid-turn Esc from scrollback must NOT cancel\nscreen:\n{screen}"
);
harness.update(Duration::from_millis(200));
// 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 from the scrollback pane.
harness.inject_keys(keys::CTRL_C).expect("press ctrl+c");
harness
.wait_for_text("Turn cancelled by user", Duration::from_secs(15))
.expect("Ctrl+C from scrollback must cancel the still-running turn");
.expect("turn cancelled marker (from scrollback)");
harness.update(Duration::from_millis(600));
let screen = harness.screen_contents();
assert_eq!(
screen.matches("Turn cancelled by user").count(),
1,
"'Turn cancelled' must appear exactly once\nscreen:\n{screen}"
);
assert!(
!harness.contains_text("panicked"),
"pager panicked\nscreen:\n{}",

View file

@ -27,13 +27,16 @@ async fn file_path_with_space_emits_full_osc8_hyperlink() {
// The default harness PTY only sets `TERM=xterm-256color`, so brand is
// `Unknown` and the pager deliberately skips OSC 8. Pin WezTerm so the
// byte-level proof below is meaningful (same override as `pty_xtversion`).
let mut env = content.env_for_pager();
env.push(("TERM_PROGRAM".into(), "WezTerm".into()));
let env_refs: Vec<(&str, &str)> = env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
let overrides: Vec<(String, String)> = vec![("TERM_PROGRAM".into(), "WezTerm".into())];
let env_refs: Vec<(&str, &str)> = overrides
.iter()
.map(|(key, value)| (key.as_str(), value.as_str()))
.collect();
// Wide enough that the path does not wrap mid-segment (wrap would still
// linkify, but we want a single-row assertion on the screen text).
let mut harness = PtyHarness::new(&binary, DEFAULT_ROWS, 160, &[], &env_refs)
.expect("spawn pager with content");
let mut harness =
PtyHarness::spawn_with_content_env(&binary, DEFAULT_ROWS, 160, &content, &[], &env_refs)
.expect("spawn pager with content");
harness
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)

View file

@ -17,15 +17,15 @@ async fn folder_trust_cwd_is_home_git_repo_no_prompt() {
git2::Repository::init(content.home()).expect("git init $HOME");
std::fs::write(content.home().join(".mcp.json"), "{}").expect("write $HOME/.mcp.json");
let env = trust_env(&content, true);
let env_refs: Vec<(&str, &str)> = env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
let env_refs = trust_env(true);
let cwd = content.home().to_str().expect("utf8 home path");
let binary = pager_binary().expect("resolve pager binary");
let mut harness = PtyHarness::new(
let mut harness = PtyHarness::spawn_with_content_env(
&binary,
DEFAULT_ROWS,
DEFAULT_COLS,
&content,
&["--cwd", cwd],
&env_refs,
)

View file

@ -10,15 +10,15 @@ use super::common::*;
async fn folder_trust_decline_quits_without_grant() {
let content = ContentController::start().await.expect("start content");
let repo = git_repo_with_mcp_json();
let env = trust_env(&content, true);
let env_refs: Vec<(&str, &str)> = env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
let env_refs = trust_env(true);
let cwd = repo.path().to_str().expect("utf8 repo path");
let binary = pager_binary().expect("resolve pager binary");
let mut harness = PtyHarness::new(
let mut harness = PtyHarness::spawn_with_content_env(
&binary,
DEFAULT_ROWS,
DEFAULT_COLS,
&content,
&["--cwd", cwd],
&env_refs,
)
@ -31,11 +31,15 @@ async fn folder_trust_decline_quits_without_grant() {
// Decline => the pager quits (no session, no grant).
harness.inject_keys(b"n").expect("inject n");
let deadline = Instant::now() + Duration::from_secs(10);
while harness.is_running() && Instant::now() < deadline {
while Instant::now() < deadline {
if !harness.is_running().expect("poll pager liveness") {
break;
}
harness.update(Duration::from_millis(100));
}
let running = harness.is_running().expect("poll pager liveness");
assert!(
!harness.is_running(),
!running,
"declining the trust question must quit the pager\nscreen:\n{}",
harness.screen_contents()
);

View file

@ -10,15 +10,15 @@ use super::common::*;
async fn folder_trust_feature_off_shows_no_question() {
let content = ContentController::start().await.expect("start content");
let repo = git_repo_with_mcp_json();
let env = trust_env(&content, false);
let env_refs: Vec<(&str, &str)> = env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
let env_refs = trust_env(false);
let cwd = repo.path().to_str().expect("utf8 repo path");
let binary = pager_binary().expect("resolve pager binary");
let mut harness = PtyHarness::new(
let mut harness = PtyHarness::spawn_with_content_env(
&binary,
DEFAULT_ROWS,
DEFAULT_COLS,
&content,
&["--cwd", cwd],
&env_refs,
)

View file

@ -21,15 +21,15 @@ async fn folder_trust_home_git_repo_subdir_keys_on_subdir() {
std::fs::create_dir_all(&proj).expect("create proj subdir");
std::fs::write(proj.join(".mcp.json"), "{}").expect("write proj/.mcp.json");
let env = trust_env(&content, true);
let env_refs: Vec<(&str, &str)> = env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
let env_refs = trust_env(true);
let cwd = proj.to_str().expect("utf8 proj path");
let binary = pager_binary().expect("resolve pager binary");
let mut harness = PtyHarness::new(
let mut harness = PtyHarness::spawn_with_content_env(
&binary,
DEFAULT_ROWS,
DEFAULT_COLS,
&content,
&["--cwd", cwd],
&env_refs,
)

View file

@ -12,15 +12,15 @@ async fn folder_trust_question_renders_and_accept_persists_grant() {
let content = ContentController::start().await.expect("start content");
content.set_response(format!("{MOCK_RESPONSE_SENTINEL} trusted and running."));
let repo = git_repo_with_mcp_json();
let env = trust_env(&content, true);
let env_refs: Vec<(&str, &str)> = env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
let env_refs = trust_env(true);
let cwd = repo.path().to_str().expect("utf8 repo path");
let binary = pager_binary().expect("resolve pager binary");
let mut harness = PtyHarness::new(
let mut harness = PtyHarness::spawn_with_content_env(
&binary,
DEFAULT_ROWS,
DEFAULT_COLS,
&content,
&["--cwd", cwd],
&env_refs,
)

View file

@ -63,8 +63,9 @@ async fn forced_wheel_mode_env_scrolls_exact_rows() {
// Outlasts the 80ms stream gap + finalize cadence with CI slack.
harness.update(std::time::Duration::from_millis(600));
let running = harness.is_running().expect("poll pager liveness");
assert!(
harness.is_running() && !harness.contains_text("panicked"),
running && !harness.contains_text("panicked"),
"pager broke during the forced-wheel burst\nscreen:\n{}",
harness.screen_contents()
);

View file

@ -24,11 +24,20 @@ async fn interjection_reaches_model_ctrl_l_in_vscode_family() {
);
let binary = pager_binary().expect("resolve pager binary");
let mut env = content.env_for_pager();
env.push(("TERM_PROGRAM".into(), "vscode".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, &[], &env_refs)
.expect("spawn pager with vscode brand");
let overrides: Vec<(String, String)> = vec![("TERM_PROGRAM".into(), "vscode".into())];
let env_refs: Vec<(&str, &str)> = overrides
.iter()
.map(|(key, value)| (key.as_str(), value.as_str()))
.collect();
let mut harness = PtyHarness::spawn_with_content_env(
&binary,
DEFAULT_ROWS,
DEFAULT_COLS,
&content,
&[],
&env_refs,
)
.expect("spawn pager with vscode brand");
harness
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)

View file

@ -41,14 +41,15 @@ async fn iterm_raw_readline_sequences_edit_picker_and_dashboard_rename() {
let content = ContentController::start().await.expect("start content");
content.set_response(format!("{MOCK_RESPONSE_SENTINEL} iTerm editing turn."));
let binary = pager_binary().expect("resolve pager binary");
let mut env = content.env_for_pager();
env.push(("TERM_PROGRAM".into(), "iTerm.app".into()));
let env_refs: Vec<(&str, &str)> = env
.iter()
.map(|(key, value)| (key.as_str(), value.as_str()))
.collect();
let mut harness =
PtyHarness::new(&binary, DEFAULT_ROWS, DEFAULT_COLS, &[], &env_refs).expect("spawn pager");
let mut harness = PtyHarness::spawn_with_content_env_ops(
&binary,
DEFAULT_ROWS,
DEFAULT_COLS,
&content,
&[],
&[EnvOp::set("TERM_PROGRAM", "iTerm.app")],
)
.expect("spawn pager");
harness
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
@ -143,8 +144,8 @@ async fn iterm_raw_readline_sequences_edit_picker_and_dashboard_rename() {
.expect("quit confirmation rendered");
harness.inject_keys(b"\x11").expect("Ctrl+Q confirm");
assert_eq!(
harness.wait_exit_code(Duration::from_secs(10)),
Some(0),
wait_for_exit_status(&mut harness, Duration::from_secs(10)).expect("wait for pager exit"),
PtyExitPoll::Exited(0),
"pager must exit cleanly"
);
}

View file

@ -7,8 +7,8 @@ use super::common::*;
#[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 managed_policy_gate_refusal_reaches_real_terminal() {
let home = tempfile::tempdir().expect("tempdir");
let home_path = home.path();
let sandbox = xai_grok_test_support::TestSandbox::new();
let home_path = sandbox.grok_home();
std::fs::write(
home_path.join("config.toml"),
// Dead local port so any incidental fetch fails fast offline (the gate is synchronous anyway).
@ -39,30 +39,32 @@ async fn managed_policy_gate_refusal_reaches_real_terminal() {
.expect("write marker");
let binary = pager_binary().expect("resolve pager binary");
let home_str = home_path.to_str().expect("utf8 home path");
let mut harness = PtyHarness::new(
let mut harness = PtyHarness::new_in_sandbox_ops(
&binary,
DEFAULT_ROWS,
DEFAULT_COLS,
&["--no-auto-update"],
&sandbox,
// GROK_MANAGED_CONFIG=0 disables the background refetch so the gate decision is deterministic and offline.
&[
("GROK_HOME", home_str),
("GROK_MANAGED_CONFIG", "0"),
("NO_COLOR", "1"),
EnvOp::set("GROK_MANAGED_CONFIG", "0"),
EnvOp::set("NO_COLOR", "1"),
],
None,
)
.expect("spawn pager");
// The gate refuses synchronously and exits; drain output, capturing the exit code once.
// The gate refuses synchronously and exits; drain output until its cached status arrives.
let gate_msg = "Managed policy is required for this account";
let deadline = Instant::now() + Duration::from_secs(30);
let mut exit_code = None;
while Instant::now() < deadline {
harness.update(Duration::from_millis(100));
// Poll non-blocking; `wait_exit_code` reaps, so capture it exactly once.
if exit_code.is_none() {
exit_code = harness.wait_exit_code(Duration::ZERO);
match wait_for_exit_status(&mut harness, Duration::ZERO).expect("poll gate exit") {
PtyExitPoll::Exited(code) => exit_code = Some(code),
PtyExitPoll::Running | PtyExitPoll::PendingStatus => {}
}
if exit_code.is_some() {
harness.update(Duration::from_millis(200)); // final drain after exit
break;

View file

@ -4,8 +4,8 @@ use super::common::*;
/// Overlay-steal precedence: while a turn is streaming, opening the slash
/// dropdown and pressing **Esc dismisses the dropdown and does NOT cancel the
/// turn** (and does not hit the mid-turn swallow). The pane-level slash handler
/// returns `Changed` before `try_handle_esc_policy` ever runs.
/// turn** (it never reaches the mid-turn Esc policy). The pane-level slash
/// handler returns `Changed` before `try_handle_esc_policy` ever runs.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore]
async fn mid_turn_slash_dropdown_esc_dismisses_not_cancel() {

View file

@ -50,22 +50,22 @@ async fn middle_click_pastes_primary_linux() {
bin_dir.display(),
std::env::var("PATH").unwrap_or_default()
);
let env: Vec<(String, String)> = {
let mut env = content.env_for_pager();
env.push(("PATH".into(), path_env));
env.push(("TERM".into(), "xterm".into()));
env.push(("DISPLAY".into(), ":99".into()));
env.push(("WAYLAND_DISPLAY".into(), String::new()));
env
};
let env_refs: Vec<(&str, &str)> = env
.iter()
.map(|(key, value)| (key.as_str(), value.as_str()))
.collect();
let overrides = [
("PATH", path_env.as_str()),
("TERM", "xterm"),
("DISPLAY", ":99"),
("WAYLAND_DISPLAY", ""),
];
let binary = pager_binary().expect("resolve pager binary");
let mut harness =
PtyHarness::new_in_dir(&binary, DEFAULT_ROWS, DEFAULT_COLS, &[], &env_refs, None)
.expect("spawn pager");
let mut harness = PtyHarness::spawn_with_content_env(
&binary,
DEFAULT_ROWS,
DEFAULT_COLS,
&content,
&[],
&overrides,
)
.expect("spawn pager");
harness
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)

View file

@ -42,11 +42,10 @@ async fn minimal_commits_thinking_body_to_scrollback() {
)
.expect("write config");
let env = content.env_for_pager();
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");
let mut harness =
PtyHarness::spawn_with_content(&binary, DEFAULT_ROWS, DEFAULT_COLS, &content, MINIMAL_ARGS)
.expect("spawn minimal pager");
harness.set_respond_to_queries(true);
wait_minimal_ready(&mut harness);

View file

@ -27,10 +27,12 @@ async fn minimal_ctrl_c_arms_and_quits() {
// 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));
let exit = harness
.wait_exit_code(Duration::from_secs(5))
.expect("wait for minimal pager exit");
assert!(
code.is_some(),
"second Ctrl+C should quit minimal\nscreen:\n{}",
matches!(exit, PtyExitPoll::Exited(_) | PtyExitPoll::PendingStatus),
"second Ctrl+C should quit minimal, got {exit:?}\nscreen:\n{}",
harness.screen_contents()
);
}

View file

@ -20,14 +20,24 @@ async fn minimal_ctrl_o_send_now_queued_apple_terminal() {
);
let binary = pager_binary().expect("resolve pager binary");
let mut env = content.env_for_pager();
env.push(("TERM_PROGRAM".into(), "Apple_Terminal".into()));
let mut overrides: Vec<(String, String)> =
vec![("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");
overrides.push(("PAGER".into(), "cat".into()));
let env_refs: Vec<(&str, &str)> = overrides
.iter()
.map(|(key, value)| (key.as_str(), value.as_str()))
.collect();
let mut harness = PtyHarness::spawn_with_content_env(
&binary,
DEFAULT_ROWS,
DEFAULT_COLS,
&content,
MINIMAL_ARGS,
&env_refs,
)
.expect("spawn minimal + Apple_Terminal");
harness.set_respond_to_queries(true);
wait_minimal_ready(&mut harness);

View file

@ -5,10 +5,10 @@ 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.
/// (via Ctrl+C here) 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() {

View file

@ -2,11 +2,13 @@
#[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.
/// Esc cancels a running turn in minimal mode (the prompt is always focused, so
/// the turn-running Esc branch wins; minimal enables the Esc-cancel gate
/// regardless of vim mode). The cancellation marker is finalized and committed
/// to native scrollback like any other block.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore]
async fn minimal_esc_mid_turn_is_swallowed() {
async fn minimal_esc_cancels_running_turn() {
let content = ContentController::start().await.expect("start content");
// Paced, long stream so the turn is provably still running when Esc lands.
let long = format!(
@ -26,27 +28,13 @@ async fn minimal_esc_mid_turn_is_swallowed() {
.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));
harness.inject_keys(keys::ESC).expect("press esc to cancel");
// 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");
.expect("cancellation marker committed to scrollback");
assert!(
!harness.contains_text("panicked"),
"pager panicked\nscreen:\n{}",

View file

@ -36,12 +36,16 @@ async fn minimal_external_editor_round_trip() {
format!("'{}'", script.display())
};
let mut env = content.env_for_pager();
env.push(("VISUAL".to_owned(), editor));
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");
let mut harness = PtyHarness::spawn_with_content_env_ops(
&binary,
DEFAULT_ROWS,
DEFAULT_COLS,
&content,
MINIMAL_ARGS,
&[EnvOp::set("VISUAL", &editor)],
)
.expect("spawn minimal pager");
harness.set_respond_to_queries(true);
wait_minimal_ready(&mut harness);

View file

@ -41,7 +41,7 @@ async fn minimal_resize_preserves_committed_scrollback() {
harness.update(Duration::from_millis(800));
assert!(
harness.is_running(),
harness.is_running().expect("poll pager liveness"),
"pager exited during resize\nscreen:\n{}",
harness.screen_contents()
);

View file

@ -17,12 +17,21 @@ async fn minimal_transcript_opens_in_pager() {
// 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 overrides: Vec<(String, String)> = vec![("PAGER".to_string(), "cat".to_string())];
let env_refs: Vec<(&str, &str)> = overrides
.iter()
.map(|(key, value)| (key.as_str(), value.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");
let mut harness = PtyHarness::spawn_with_content_env(
&binary,
DEFAULT_ROWS,
DEFAULT_COLS,
&content,
MINIMAL_ARGS,
&env_refs,
)
.expect("spawn minimal pager");
harness.set_respond_to_queries(true);
wait_minimal_ready(&mut harness);

View file

@ -43,16 +43,25 @@ async fn minimal_transcript_pager_restore_no_artifacts() {
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((
let mut overrides: Vec<(String, String)> = vec![("PAGER".to_string(), "less".to_string())];
overrides.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 env_refs: Vec<(&str, &str)> = overrides
.iter()
.map(|(key, value)| (key.as_str(), value.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");
let mut harness = PtyHarness::spawn_with_content_env(
&binary,
DEFAULT_ROWS,
DEFAULT_COLS,
&content,
MINIMAL_ARGS,
&env_refs,
)
.expect("spawn minimal pager");
harness.set_respond_to_queries(true);
wait_minimal_ready(&mut harness);

View file

@ -14,7 +14,7 @@ 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_esc_cancels_running_turn;
mod minimal_external_editor_round_trip;
mod minimal_flush_left_no_hpad;
mod minimal_help_opens_command_palette;

View file

@ -82,7 +82,7 @@ async fn misclassified_wheel_flood_does_not_teleport_viewport() {
harness.update(Duration::from_millis(800));
assert!(
harness.is_running(),
harness.is_running().expect("poll pager liveness"),
"pager exited during the wheel flood\nscreen:\n{}",
harness.screen_contents()
);

View file

@ -42,8 +42,8 @@ async fn mouse_reporting_toggle_sticky_persists_pty() {
let toggle_visible =
|h: &PtyHarness| sticky_visible(h) || h.contains_text("Mouse reporting on");
// Defocus the prompt so scrollback owns keys — Tab is leave-prompt
// (Esc is clear/rewind idle / mid-turn swallow). Tab TOGGLES focus, so never re-press it
// Defocus the prompt so scrollback owns keys — Tab is leave-prompt (Esc is
// reserved for the cancel / clear / rewind policy). Tab TOGGLES focus, so never re-press it
// blindly (a lagged frame would bounce focus back to the prompt). Idempotent:
// return if the scrollback already owns keys, else a SINGLE Tab + wait for
// the footer's "Space:prompt" to render (mirrors `drive_to_scrollback_with_turn`).

View file

@ -19,16 +19,19 @@ async fn nested_quote_drag_copy_excludes_bars_pty() {
));
let binary = pager_binary().expect("resolve pager binary");
let mut env = content.env_for_pager();
env.push((
let overrides: Vec<(String, String)> = vec![(
"SSH_CONNECTION".into(),
"scripted-test 1 127.0.0.1 2".into(),
));
let env_refs: Vec<(&str, &str)> = env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
let mut harness = PtyHarness::new_in_dir(
)];
let env_refs: Vec<(&str, &str)> = overrides
.iter()
.map(|(key, value)| (key.as_str(), value.as_str()))
.collect();
let mut harness = PtyHarness::spawn_with_content_env_in_dir(
&binary,
DEFAULT_ROWS,
DEFAULT_COLS,
&content,
&[],
&env_refs,
Some(content.home()),

View file

@ -38,15 +38,23 @@ async fn prompt_suggestion_ghost_tab_accepts() {
.expect("start content");
content.set_response(SUGGESTION);
// env_for_pager disables the feature for the suite; re-enable it here.
let mut env = content.env_for_pager();
env.retain(|(k, _)| k != "GROK_PROMPT_SUGGESTIONS");
env.push(("GROK_PROMPT_SUGGESTIONS".into(), "true".into()));
let env_refs: Vec<(&str, &str)> = env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
// The sandbox baseline disables the feature for the suite; re-enable it here.
let overrides = [("GROK_PROMPT_SUGGESTIONS".to_owned(), "true".to_owned())];
let env_refs: Vec<(&str, &str)> = overrides
.iter()
.map(|(key, value)| (key.as_str(), value.as_str()))
.collect();
let binary = pager_binary().expect("resolve pager binary");
let mut harness =
PtyHarness::new(&binary, DEFAULT_ROWS, DEFAULT_COLS, &[], &env_refs).expect("spawn pager");
let mut harness = PtyHarness::spawn_with_content_env(
&binary,
DEFAULT_ROWS,
DEFAULT_COLS,
&content,
&[],
&env_refs,
)
.expect("spawn pager");
harness
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)

View file

@ -47,16 +47,19 @@ async fn quote_block_drag_copy_excludes_bars_pty() {
));
let binary = pager_binary().expect("resolve pager binary");
let mut env = content.env_for_pager();
env.push((
let overrides: Vec<(String, String)> = vec![(
"SSH_CONNECTION".into(),
"scripted-test 1 127.0.0.1 2".into(),
));
let env_refs: Vec<(&str, &str)> = env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
let mut harness = PtyHarness::new_in_dir(
)];
let env_refs: Vec<(&str, &str)> = overrides
.iter()
.map(|(key, value)| (key.as_str(), value.as_str()))
.collect();
let mut harness = PtyHarness::spawn_with_content_env_in_dir(
&binary,
DEFAULT_ROWS,
DEFAULT_COLS,
&content,
&[],
&env_refs,
Some(content.home()),

View file

@ -23,16 +23,19 @@ async fn quote_block_raw_mode_copy_keeps_source_pty() {
));
let binary = pager_binary().expect("resolve pager binary");
let mut env = content.env_for_pager();
env.push((
let overrides: Vec<(String, String)> = vec![(
"SSH_CONNECTION".into(),
"scripted-test 1 127.0.0.1 2".into(),
));
let env_refs: Vec<(&str, &str)> = env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
let mut harness = PtyHarness::new_in_dir(
)];
let env_refs: Vec<(&str, &str)> = overrides
.iter()
.map(|(key, value)| (key.as_str(), value.as_str()))
.collect();
let mut harness = PtyHarness::spawn_with_content_env_in_dir(
&binary,
DEFAULT_ROWS,
DEFAULT_COLS,
&content,
&[],
&env_refs,
Some(content.home()),

View file

@ -26,21 +26,24 @@ async fn read_tool_header_selection_copies_path_only_pty() {
let _read_turn = seed_read_file_tool_call(&content, &abs_path);
let binary = pager_binary().expect("resolve pager binary");
let mut env = content.env_for_pager();
env.push((
let overrides: Vec<(String, String)> = vec![(
"SSH_CONNECTION".into(),
"scripted-test 1 127.0.0.1 2".into(),
));
let env_refs: Vec<(&str, &str)> = env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
)];
let env_refs: Vec<(&str, &str)> = overrides
.iter()
.map(|(key, value)| (key.as_str(), value.as_str()))
.collect();
// The invariant under test is the RAW `Read {path}` header's selectable
// span; with verb-group folding on (default), even a lone read folds into
// the aggregated "Read 1 file" label and the path row never renders.
seed_ui_config(&content, "group_tool_verbs = false");
let mut harness = PtyHarness::new_in_dir(
let mut harness = PtyHarness::spawn_with_content_env_in_dir(
&binary,
DEFAULT_ROWS,
DEFAULT_COLS,
&content,
&[],
&env_refs,
Some(content.home()),

View file

@ -10,6 +10,7 @@ use super::common::*;
async fn reasoning_efforts_menu_renders_and_remaps_on_wire() {
let content = ContentController::start_with_models(vec![
MockModel::new("grok-4.5")
.with_api_backend("responses")
.with_supports_reasoning_effort(true)
.with_reasoning_efforts(vec![
json!({ "id": "deep", "value": "xhigh", "label": "Deep", "description": "Maximum reasoning" }),

View file

@ -30,18 +30,21 @@ async fn recap_header_not_in_selection_pty() {
));
let binary = pager_binary().expect("resolve pager binary");
let mut env = content.env_for_pager();
// Force OSC 52 so we can assert clipboard contents via the PTY raw stream
// (macOS otherwise uses the native pasteboard only).
env.push((
let overrides: Vec<(String, String)> = vec![(
"SSH_CONNECTION".into(),
"scripted-test 1 127.0.0.1 2".into(),
));
let env_refs: Vec<(&str, &str)> = env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
let mut harness = PtyHarness::new_in_dir(
)];
let env_refs: Vec<(&str, &str)> = overrides
.iter()
.map(|(key, value)| (key.as_str(), value.as_str()))
.collect();
let mut harness = PtyHarness::spawn_with_content_env_in_dir(
&binary,
DEFAULT_ROWS,
DEFAULT_COLS,
&content,
&[],
&env_refs,
Some(content.home()),

View file

@ -109,8 +109,13 @@ fn quit_gracefully(mut harness: PtyHarness) {
harness.inject_keys(b"\x11").expect("ctrl-q arm");
harness.update(Duration::from_millis(200));
harness.inject_keys(b"\x11").expect("ctrl-q confirm");
let code = harness.wait_exit_code(Duration::from_secs(10));
assert_eq!(code, Some(0), "graceful quit should exit 0, got {code:?}");
let exit = wait_for_exit_status(&mut harness, Duration::from_secs(10))
.expect("wait for graceful quit");
assert_eq!(
exit,
PtyExitPoll::Exited(0),
"graceful quit should exit 0, got {exit:?}"
);
}
/// Spawn a pager in `project` against `content`, submit one turn, and settle.

View file

@ -14,7 +14,7 @@ use super::common::*;
/// Running-turn keybar hint; absent while the parked look is active.
#[cfg(unix)]
const CANCEL_HINT: &str = "Ctrl+c:cancel";
const CANCEL_HINT: &str = "Esc:cancel";
/// Between-parks sentinel: collapsed execute blocks render "Run
/// <description>", not the command's stdout.

View file

@ -8,8 +8,8 @@ use super::common::*;
#[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 requirements_version_failure_exits_2_with_guidance() {
let home = tempfile::tempdir().expect("tempdir");
let home_path = home.path();
let sandbox = xai_grok_test_support::TestSandbox::new();
let home_path = sandbox.grok_home();
// fail_closed + a version_override whose version can't parse → apply_version_overrides errs → startup aborts.
std::fs::write(
home_path.join("requirements.toml"),
@ -18,13 +18,14 @@ async fn requirements_version_failure_exits_2_with_guidance() {
.expect("write requirements.toml");
let binary = pager_binary().expect("resolve pager binary");
let home_str = home_path.to_str().expect("utf8 home path");
let mut harness = PtyHarness::new(
let mut harness = PtyHarness::new_in_sandbox_ops(
&binary,
DEFAULT_ROWS,
DEFAULT_COLS,
&["--no-auto-update"],
&[("GROK_HOME", home_str), ("NO_COLOR", "1")],
&sandbox,
&[EnvOp::set("NO_COLOR", "1")],
None,
)
.expect("spawn pager");
@ -46,12 +47,22 @@ async fn requirements_version_failure_exits_2_with_guidance() {
if harness.contains_text(msg) || String::from_utf8_lossy(harness.raw_output()).contains(msg)
{
if exit_code.is_none() {
exit_code = harness.wait_exit_code(Duration::from_secs(2));
match wait_for_exit_status(&mut harness, Duration::from_secs(2))
.expect("wait for requirements exit")
{
PtyExitPoll::Exited(code) => exit_code = Some(code),
PtyExitPoll::Running | PtyExitPoll::PendingStatus => {}
}
}
break;
}
if exit_code.is_none() {
exit_code = harness.wait_exit_code(Duration::ZERO);
match wait_for_exit_status(&mut harness, Duration::ZERO)
.expect("poll requirements exit")
{
PtyExitPoll::Exited(code) => exit_code = Some(code),
PtyExitPoll::Running | PtyExitPoll::PendingStatus => {}
}
if exit_code.is_some() {
// The child exited before the guidance surfaced on our side.
// It wrote the guidance to fd 2 just before exiting; keep

View file

@ -252,7 +252,7 @@ async fn resize_preserves_scroll_position() {
let screen_after = harness.screen_contents();
assert!(
harness.is_running(),
harness.is_running().expect("poll pager liveness"),
"pager exited during resize\nscreen:\n{screen_after}"
);
assert!(

View file

@ -53,7 +53,7 @@ async fn reverse_agent_type_mismatch_cursor_to_default() {
.expect("agent type mismatch modal should appear for reverse direction");
assert!(
harness.is_running(),
harness.is_running().expect("poll pager liveness"),
"pager exited\nscreen:\n{}",
harness.screen_contents()
);

View file

@ -170,15 +170,15 @@ pub(crate) async fn spawn_bottom_pinned_marker_scrollback_with_env(
content.set_response(marker_response(MOCK_RESPONSE_SENTINEL, marker_count));
let binary = pager_binary().expect("resolve pager binary");
// spawn_with_content minus the fixed env: content env + the caller's.
let content_env = content.env_for_pager();
let mut env: Vec<(&str, &str)> = content_env
.iter()
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect();
env.extend_from_slice(extra_env);
let mut harness = PtyHarness::new(&binary, DEFAULT_ROWS, DEFAULT_COLS, &[], &env)
.expect("spawn pager with content");
let mut harness = PtyHarness::spawn_with_content_env(
&binary,
DEFAULT_ROWS,
DEFAULT_COLS,
&content,
&[],
extra_env,
)
.expect("spawn pager with content");
harness
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
@ -271,15 +271,15 @@ pub(crate) async fn spawn_streaming_marker_turn(
);
let binary = pager_binary().expect("resolve pager binary");
// spawn_with_content minus the fixed env: content env + the caller's.
let content_env = content.env_for_pager();
let mut env: Vec<(&str, &str)> = content_env
.iter()
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect();
env.extend_from_slice(extra_env);
let mut harness = PtyHarness::new(&binary, DEFAULT_ROWS, DEFAULT_COLS, &[], &env)
.expect("spawn pager with content");
let mut harness = PtyHarness::spawn_with_content_env(
&binary,
DEFAULT_ROWS,
DEFAULT_COLS,
&content,
&[],
extra_env,
)
.expect("spawn pager with content");
harness
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)

View file

@ -74,8 +74,9 @@ async fn scroll_debug_hud_env_shows_hud_and_tracks_flood() {
);
harness.update(Duration::from_millis(300));
let running = harness.is_running().expect("poll pager liveness");
assert!(
harness.is_running() && !harness.contains_text("panicked"),
running && !harness.contains_text("panicked"),
"pager broke during the HUD flood\nscreen:\n{}",
harness.screen_contents()
);
@ -160,8 +161,9 @@ async fn debug_scroll_command_toggles_hud_live() {
"HUD must clear after the second /debug scroll\nscreen:\n{}",
harness.screen_contents()
);
let running = harness.is_running().expect("poll pager liveness");
assert!(
harness.is_running() && !harness.contains_text("panicked"),
running && !harness.contains_text("panicked"),
"pager broke during the /debug scroll round trip\nscreen:\n{}",
harness.screen_contents()
);

View file

@ -37,7 +37,7 @@ async fn scroll_does_not_crash() {
harness.update(Duration::from_millis(250));
assert!(
harness.is_running(),
harness.is_running().expect("poll pager liveness"),
"pager exited during scroll\nscreen:\n{}",
harness.screen_contents()
);

View file

@ -16,10 +16,16 @@ async fn send_now_tip_after_mid_turn_queue() {
);
let binary = pager_binary().expect("resolve pager binary");
let env = contextual_hints_env(&content);
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, &[], &env_refs)
.expect("spawn pager with contextual hints");
let env_refs = CONTEXTUAL_HINTS_ENV;
let mut harness = PtyHarness::spawn_with_content_env(
&binary,
DEFAULT_ROWS,
DEFAULT_COLS,
&content,
&[],
env_refs,
)
.expect("spawn pager with contextual hints");
harness
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)

View file

@ -14,14 +14,14 @@ async fn shift_tab_plan_nudge_from_always_approve_enters_plan() {
let binary = pager_binary().expect("resolve pager binary");
// --yolo/--trust seed Always-Approve; hints env opts the tip in; CWD is
// the sandboxed content home so trust resolves against the same tree.
let env = contextual_hints_env(&content);
let env_refs: Vec<(&str, &str)> = env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
let mut harness = PtyHarness::new_in_dir(
let env_refs = CONTEXTUAL_HINTS_ENV;
let mut harness = PtyHarness::spawn_with_content_env_in_dir(
&binary,
DEFAULT_ROWS,
DEFAULT_COLS,
&content,
&["--yolo", "--trust"],
&env_refs,
env_refs,
Some(content.home()),
)
.expect("spawn pager in always-approve");

View file

@ -29,10 +29,16 @@ async fn small_screen_tip_survives_slow_turn() {
content.set_chunk_delay(Some(Duration::from_millis(400)));
let binary = pager_binary().expect("resolve pager binary");
let env = contextual_hints_env(&content);
let env_refs: Vec<(&str, &str)> = env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
let mut harness =
PtyHarness::new(&binary, BAND_ROWS, DEFAULT_COLS, &[], &env_refs).expect("spawn");
let env_refs = CONTEXTUAL_HINTS_ENV;
let mut harness = PtyHarness::spawn_with_content_env(
&binary,
BAND_ROWS,
DEFAULT_COLS,
&content,
&[],
env_refs,
)
.expect("spawn");
// The prompt marker paints at every height; the first char promotes the
// welcome prompt to the agent view, where the tip fires.
@ -49,7 +55,7 @@ async fn small_screen_tip_survives_slow_turn() {
harness.update(Duration::from_millis(1500));
let mid_turn = harness.screen_contents();
assert!(
harness.is_running(),
harness.is_running().expect("poll pager liveness"),
"pager exited mid-turn\nscreen:\n{mid_turn}"
);
assert!(

View file

@ -15,7 +15,7 @@ use super::common::*;
/// Running-turn keybar hint; absent while the parked look is active
/// (see `wait_for_turn_idle` in common.rs for the same sentinel).
#[cfg(unix)]
const CANCEL_HINT: &str = "Ctrl+c:cancel";
const CANCEL_HINT: &str = "Esc:cancel";
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]

View file

@ -23,18 +23,25 @@ async fn storage_upload_parks_on_401_and_drains_after_recovery() {
// under test.
seed_fake_oauth(&content, "pty-park-e2e");
// Appended last so they win over the harness defaults.
let env = oauth_env_for_pager(&content);
let mut env_refs: Vec<(&str, &str)> =
env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
env_refs.retain(|(k, _)| *k != "GROK_TRACE_UPLOAD");
env_refs.push(("GROK_TRACE_UPLOAD", "true"));
env_refs.push(("GROK_TELEMETRY_TRACE_UPLOAD", "true"));
env_refs.push(("GROK_UPLOAD_QUEUE_AUTH_PROBE_SECS", "2"));
// Explicit overrides win over the sandbox defaults. Disable only the fake
// API-key credential so seeded OAuth remains active.
let overrides = [
oauth_credential_ops()[0],
EnvOp::set("GROK_TRACE_UPLOAD", "true"),
EnvOp::set("GROK_TELEMETRY_TRACE_UPLOAD", "true"),
EnvOp::set("GROK_UPLOAD_QUEUE_AUTH_PROBE_SECS", "2"),
];
let binary = pager_binary().expect("resolve pager binary");
let mut harness = PtyHarness::new(&binary, DEFAULT_ROWS, DEFAULT_COLS, &[], &env_refs)
.expect("spawn pager with storage-401 mock");
let mut harness = PtyHarness::spawn_with_content_env_ops(
&binary,
DEFAULT_ROWS,
DEFAULT_COLS,
&content,
&[],
&overrides,
)
.expect("spawn pager with storage-401 mock");
harness
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
@ -91,7 +98,10 @@ async fn storage_upload_parks_on_401_and_drains_after_recovery() {
"parked queue must not spam storage: {parked_count} -> {after} \
(allowed +{MAX_EXTRA_WHILE_PARKED})"
);
assert!(harness.is_running(), "pager stays healthy while parked");
assert!(
harness.is_running().expect("poll pager liveness"),
"pager stays healthy while parked"
);
content.set_storage_unauthorized(false);
let deadline = std::time::Instant::now() + Duration::from_secs(30);

View file

@ -119,7 +119,10 @@ async fn stuck_drag_recovers_on_esc_pty() {
"pager panicked\nscreen:\n{}",
harness.screen_contents()
);
assert!(harness.is_running(), "pager should still be running");
assert!(
harness.is_running().expect("poll pager liveness"),
"pager should still be running"
);
harness.quit().expect("clean quit");
}

View file

@ -181,22 +181,21 @@ fn seed_fake_oauth_local_issuer(content: &ContentController, user: &str) {
fn spawn_subscription_pager(
content: &ContentController,
oauth_user: &str,
extra_env: &[(&str, &str)],
extra_env: &[EnvOp<'_>],
) -> PtyHarness {
seed_fake_oauth_local_issuer(content, oauth_user);
let env = oauth_env_for_pager(content);
let mut env_refs: Vec<(&str, &str)> =
env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
env_refs.push(("GROK_LOCAL_AUTH", "1"));
env_refs.extend_from_slice(extra_env);
let mut overrides = Vec::from(oauth_credential_ops());
overrides.push(EnvOp::set("GROK_LOCAL_AUTH", "1"));
overrides.extend_from_slice(extra_env);
let binary = pager_binary().expect("resolve pager binary");
PtyHarness::new_in_dir(
PtyHarness::spawn_with_content_env_ops_in_dir(
&binary,
DEFAULT_ROWS,
DEFAULT_COLS,
content,
&[],
&env_refs,
&overrides,
Some(content.home()),
)
.expect("spawn pager with subscription session auth")
@ -207,7 +206,7 @@ fn spawn_subscription_pager(
fn spawn_subscription_session(
content: &ContentController,
oauth_user: &str,
extra_env: &[(&str, &str)],
extra_env: &[EnvOp<'_>],
) -> PtyHarness {
let mut harness = spawn_subscription_pager(content, oauth_user, extra_env);
harness
@ -241,7 +240,7 @@ async fn subscription_watch_polls_free_tier_then_goes_dormant_after_upgrade() {
let mut harness = spawn_subscription_session(
&content,
"pty-subwatch",
&[("GROK_SUBSCRIPTION_WATCH_INTERVAL_SECS", "1")],
&[EnvOp::set("GROK_SUBSCRIPTION_WATCH_INTERVAL_SECS", "1")],
);
// While free, the watch fires repeatedly at the (test-shrunk) cadence.
@ -388,7 +387,7 @@ async fn stale_gate_push_never_flashes_paywall_for_subscribed_user() {
let mut harness = spawn_subscription_session(
&content,
"pty-subgate-paid",
&[("GROK_SUBSCRIPTION_WATCH_INTERVAL_SECS", "0")],
&[EnvOp::set("GROK_SUBSCRIPTION_WATCH_INTERVAL_SECS", "0")],
);
// Let startup fetches fully settle so the scripted one-shot below can

View file

@ -111,7 +111,7 @@ async fn trackpad_flood_does_not_under_travel() {
harness.update(Duration::from_millis(800));
assert!(
harness.is_running(),
harness.is_running().expect("poll pager liveness"),
"pager exited during the trackpad flood\nscreen:\n{}",
harness.screen_contents()
);

View file

@ -16,16 +16,22 @@ async fn undo_tip_resets_each_new_session() {
let binary = pager_binary().expect("resolve pager binary");
// Same env (same $HOME TempDir) for both spawns. Contextual hints ship
// default-OFF, so opt in explicitly or the undo tip never shows.
let env = contextual_hints_env(&content);
let env_refs: Vec<(&str, &str)> = env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
let env_refs = CONTEXTUAL_HINTS_ENV;
// Run 1: drive the in-memory seen count to its cap (3 TTL-spaced shows),
// so the count is exhausted before quitting. Each new show needs the
// previous banner to expire via its ~3s TTL first — re-wiping while it is
// still visible only refreshes the TTL without incrementing the count.
{
let mut harness = PtyHarness::new(&binary, DEFAULT_ROWS, DEFAULT_COLS, &[], &env_refs)
.expect("spawn run 1");
let mut harness = PtyHarness::spawn_with_content_env(
&binary,
DEFAULT_ROWS,
DEFAULT_COLS,
&content,
&[],
env_refs,
)
.expect("spawn run 1");
harness
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
.expect("welcome run 1");
@ -56,8 +62,15 @@ async fn undo_tip_resets_each_new_session() {
// Run 2: SAME $HOME. A persisted cap would suppress the tip here;
// per-session in-memory state means it shows again.
{
let mut harness = PtyHarness::new(&binary, DEFAULT_ROWS, DEFAULT_COLS, &[], &env_refs)
.expect("spawn run 2");
let mut harness = PtyHarness::spawn_with_content_env(
&binary,
DEFAULT_ROWS,
DEFAULT_COLS,
&content,
&[],
env_refs,
)
.expect("spawn run 2");
harness
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
.expect("welcome run 2");

View file

@ -12,10 +12,16 @@ async fn undo_tip_seen_count_never_persisted() {
let content = ContentController::start().await.expect("start content");
let binary = pager_binary().expect("resolve pager binary");
// Contextual hints ship default-OFF; opt in explicitly so the tip shows.
let env = contextual_hints_env(&content);
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, &[], &env_refs).expect("spawn");
let env_refs = CONTEXTUAL_HINTS_ENV;
let mut harness = PtyHarness::spawn_with_content_env(
&binary,
DEFAULT_ROWS,
DEFAULT_COLS,
&content,
&[],
env_refs,
)
.expect("spawn");
harness
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
.expect("welcome");

View file

@ -13,10 +13,16 @@ async fn undo_tip_session_cap_blocks_fourth_show() {
let content = ContentController::start().await.expect("start content");
let binary = pager_binary().expect("resolve pager binary");
// Contextual hints ship default-OFF; opt in explicitly so the tip shows.
let env = contextual_hints_env(&content);
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, &[], &env_refs).expect("spawn");
let env_refs = CONTEXTUAL_HINTS_ENV;
let mut harness = PtyHarness::spawn_with_content_env(
&binary,
DEFAULT_ROWS,
DEFAULT_COLS,
&content,
&[],
env_refs,
)
.expect("spawn");
harness
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
.expect("welcome");

View file

@ -75,16 +75,19 @@ async fn verb_group_header_drag_copy_pty() {
let binary = pager_binary().expect("resolve pager binary");
// SSH_CONNECTION so macOS routes the copy through OSC 52 (readback path);
// same pattern as read_tool_header_selection_copies_path_only_pty.
let mut env = content.env_for_pager();
env.push((
let overrides: Vec<(String, String)> = vec![(
"SSH_CONNECTION".into(),
"scripted-test 1 127.0.0.1 2".into(),
));
let env_refs: Vec<(&str, &str)> = env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
let mut harness = PtyHarness::new_in_dir(
)];
let env_refs: Vec<(&str, &str)> = overrides
.iter()
.map(|(key, value)| (key.as_str(), value.as_str()))
.collect();
let mut harness = PtyHarness::spawn_with_content_env_in_dir(
&binary,
DEFAULT_ROWS,
DEFAULT_COLS,
&content,
&[],
&env_refs,
Some(content.home()),

View file

@ -60,7 +60,7 @@ async fn wheel_burst_scrolls_viewport_without_frame_amplification() {
harness.update(Duration::from_millis(600));
assert!(
harness.is_running(),
harness.is_running().expect("poll pager liveness"),
"pager exited during the wheel burst\nscreen:\n{}",
harness.screen_contents()
);
@ -121,8 +121,9 @@ async fn wheel_burst_scrolls_viewport_without_frame_amplification() {
BURST_INTERVAL,
);
harness.update(Duration::from_millis(300));
let running = harness.is_running().expect("poll pager liveness");
assert!(
harness.is_running() && !harness.contains_text("panicked"),
running && !harness.contains_text("panicked"),
"pager broke on a mixed-direction wheel sequence\nscreen:\n{}",
harness.screen_contents()
);

View file

@ -83,7 +83,7 @@ async fn wheel_flood_paints_no_ghost_frames() {
harness.update(Duration::from_millis(600));
assert!(
harness.is_running(),
harness.is_running().expect("poll pager liveness"),
"pager exited during the wheel flood\nscreen:\n{}",
harness.screen_contents()
);

View file

@ -119,8 +119,9 @@ async fn wheel_overscroll_at_bottom_reengages_follow_mid_stream() {
Duration::ZERO,
);
harness.update(Duration::from_millis(800));
let running = harness.is_running().expect("poll pager liveness");
assert!(
harness.is_running() && !harness.contains_text("panicked"),
running && !harness.contains_text("panicked"),
"pager broke during the wheel dance\nscreen:\n{}",
harness.screen_contents()
);

View file

@ -74,7 +74,7 @@ async fn wheel_scrolls_viewport_during_streaming_turn() {
harness.update(Duration::from_millis(600));
assert!(
harness.is_running(),
harness.is_running().expect("poll pager liveness"),
"pager exited during the mid-stream wheel burst\nscreen:\n{}",
harness.screen_contents()
);

View file

@ -24,9 +24,8 @@ fn double_click_at(harness: &mut PtyHarness, row: u16, col: u16) {
fn spawn_with_hints(content: &ContentController) -> PtyHarness {
let binary = pager_binary().expect("resolve pager binary");
let env = contextual_hints_env(content);
let env_refs: Vec<(&str, &str)> = env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
PtyHarness::new(&binary, DEFAULT_ROWS, DEFAULT_COLS, &[], &env_refs)
let env_refs = CONTEXTUAL_HINTS_ENV;
PtyHarness::spawn_with_content_env(&binary, DEFAULT_ROWS, DEFAULT_COLS, content, &[], env_refs)
.expect("spawn pager with contextual hints")
}
@ -227,11 +226,20 @@ async fn word_select_tip_skipped_when_contextual_hint_disabled() {
// Content env only — pin the env master to empty (parsed as unset) so an
// inherited GROK_CONTEXTUAL_HINTS from the runner's shell can't force
// tips on and defeat the config opt-out under test.
let mut env = content.env_for_pager();
env.push(("GROK_CONTEXTUAL_HINTS".into(), String::new()));
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, &[], &env_refs).expect("spawn pager");
let overrides: Vec<(String, String)> = vec![("GROK_CONTEXTUAL_HINTS".into(), String::new())];
let env_refs: Vec<(&str, &str)> = overrides
.iter()
.map(|(key, value)| (key.as_str(), value.as_str()))
.collect();
let mut harness = PtyHarness::spawn_with_content_env(
&binary,
DEFAULT_ROWS,
DEFAULT_COLS,
&content,
&[],
&env_refs,
)
.expect("spawn pager");
harness
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)

View file

@ -48,7 +48,7 @@ async fn zero_turn_model_switch_no_modal() {
harness.screen_contents()
);
assert!(
harness.is_running(),
harness.is_running().expect("poll pager liveness"),
"pager exited\nscreen:\n{}",
harness.screen_contents()
);