1282 lines
51 KiB
Rust
1282 lines
51 KiB
Rust
//! Shared constants and helpers for PTY e2e tests.
|
|
//!
|
|
//! Individual test modules import via `use super::common::*`.
|
|
|
|
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::{
|
|
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,
|
|
};
|
|
|
|
/// Default PTY size used by every e2e test. Large enough to render the
|
|
/// welcome screen without wrapping, small enough to make `screen_contents()`
|
|
/// scans cheap.
|
|
pub(crate) const DEFAULT_ROWS: u16 = 50;
|
|
|
|
pub(crate) const DEFAULT_COLS: u16 = 120;
|
|
|
|
/// Default wait-for-welcome timeout. The pager spawns a child shell agent,
|
|
/// which can take a few seconds on cold build directories.
|
|
pub(crate) const WELCOME_TIMEOUT: Duration = Duration::from_secs(20);
|
|
|
|
/// Substring we wait for on the welcome screen. Matches the menu label `"Quit"`
|
|
/// (`render_welcome_done` / gate menus); case-sensitive, so it does **not**
|
|
/// match the lowercase `"quit"` hint line during `AuthState::Authenticating`.
|
|
pub(crate) const WELCOME_SCREEN_SENTINEL: &str = "Quit";
|
|
|
|
/// Prompt sent to the agent in content-driven tests. Short so it submits
|
|
/// quickly and doesn't wrap.
|
|
pub(crate) const PROMPT: &str = "go";
|
|
|
|
/// Response the mock server will stream back. Must contain a stable,
|
|
/// unambiguous sentinel word that we can `wait_for_text` on.
|
|
pub(crate) const MOCK_RESPONSE_SENTINEL: &str = "MOCKRESPONSE";
|
|
|
|
// ── Undo-tip e2e helpers ────────────────────────────────────────────────
|
|
|
|
/// Suffix of the undo-tip banner, now "Input cleared · ctrl+z to undo" on all
|
|
/// platforms (terminals don't forward Cmd+Z to a raw-mode TUI). Asserting the
|
|
/// suffix keeps the check chord-agnostic regardless.
|
|
pub(crate) const UNDO_TIP_SENTINEL: &str = "to undo";
|
|
|
|
/// A >= FIRE_PEAK_LEN (20) char draft. The first char promotes the welcome
|
|
/// prompt to a real (routed) agent session; the rest accumulate into the draft.
|
|
pub(crate) const SUBSTANTIAL_DRAFT: &[u8] = b"aaaaaaaaaaaaaaaaaaaaaaaaa";
|
|
|
|
/// Type a substantial draft, wait for it to render in the promoted agent
|
|
/// prompt, then wipe it with Ctrl+U (0x15, kill-to-BOL) — a substantial,
|
|
/// recoverable wipe that triggers the undo tip. Typing and the kill are
|
|
/// injected separately (with a settle in between) to avoid racing the async
|
|
/// welcome→session promotion; the same shape the scripted scenarios use.
|
|
pub(crate) fn wipe_substantial_draft(harness: &mut PtyHarness) {
|
|
harness
|
|
.inject_keys(SUBSTANTIAL_DRAFT)
|
|
.expect("type substantial draft");
|
|
harness
|
|
.wait_for_text("aaaaaaaaaa", Duration::from_secs(10))
|
|
.expect("draft renders in the agent prompt before the wipe");
|
|
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
|
|
}
|
|
|
|
/// Collect short OSC 8 payloads for assertion failure messages.
|
|
pub(crate) fn osc8_snippets(raw: &str) -> String {
|
|
let mut out = Vec::new();
|
|
for part in raw.split("\x1b]8;") {
|
|
if part.is_empty() {
|
|
continue;
|
|
}
|
|
let end = part
|
|
.find(['\u{7}', '\x1b'])
|
|
.unwrap_or_else(|| part.len().min(120));
|
|
let snippet = &part[..end];
|
|
if snippet.contains("file://") || snippet.contains("Demo") {
|
|
out.push(snippet.chars().take(160).collect::<String>());
|
|
}
|
|
if out.len() >= 6 {
|
|
break;
|
|
}
|
|
}
|
|
if out.is_empty() {
|
|
"(no file:// OSC 8 payloads found)".into()
|
|
} else {
|
|
out.join(" | ")
|
|
}
|
|
}
|
|
|
|
pub(crate) fn long_response(sentinel: &str, lines: usize) -> String {
|
|
let mut s = String::with_capacity(lines * 64);
|
|
s.push_str(sentinel);
|
|
s.push_str(" — scroll payload follows.\n\n");
|
|
for i in 0..lines {
|
|
s.push_str(&format!(
|
|
"Line {i}: the quick brown fox jumps over the lazy dog and keeps on going.\n"
|
|
));
|
|
}
|
|
s
|
|
}
|
|
|
|
/// A response that renders to **at least `rows` terminal rows**, with `sentinel`
|
|
/// on the first row so it is the first line to scroll into native scrollback.
|
|
///
|
|
/// Unlike [`long_response`], each source line is wrapped in a fenced code block
|
|
/// so markdown does **not** reflow the lines into one soft-wrapped paragraph —
|
|
/// each `line N` becomes exactly one rendered row. This is what makes the block
|
|
/// genuinely taller than the screen (a 60-*line* prose paragraph reflows to only
|
|
/// ~30 rows at typical widths and fits on screen, so it would *not* overflow into
|
|
/// scrollback — the content-anchored live region correctly keeps it visible).
|
|
///
|
|
/// Use this for the commit-to-scrollback contract tests, which need the block's
|
|
/// own head to scroll above the pinned viewport into the terminal's native
|
|
/// history.
|
|
pub(crate) fn tall_response(sentinel: &str, rows: usize) -> String {
|
|
let mut s = String::with_capacity(rows * 24);
|
|
s.push_str("```\n");
|
|
s.push_str(sentinel);
|
|
s.push_str(" — scroll payload follows.\n");
|
|
for i in 0..rows {
|
|
s.push_str(&format!("line {i} payload\n"));
|
|
}
|
|
s.push_str("```\n");
|
|
s
|
|
}
|
|
|
|
// ── Fake session-auth (OAuth) seeding ───────────────────────────────────
|
|
// `seed_fake_oauth` / `oauth_env_for_pager` live in
|
|
// `xai_grok_pager_pty_harness::flows` (re-exported above).
|
|
|
|
/// Spawn a pager with fake session (OAuth) auth and a 1s announcements poll,
|
|
/// then drive it into a live session (welcome → prompt → mock response).
|
|
/// Session auth matters: the settings poll requires `auth_manager.auth()`, and
|
|
/// the harness's default `XAI_API_KEY` (ApiKey/BYOK mode, no auth.json entry)
|
|
/// would never fetch `/v1/settings`. Spawns WITHOUT `GROK_ANNOUNCEMENTS_OVERRIDE`
|
|
/// (the env override beats pushed lists in the pager and would mask updates).
|
|
/// Call `content.set_response(..)` BEFORE this so the entry prompt streams.
|
|
pub(crate) fn spawn_polling_session(content: &ContentController, oauth_user: &str) -> PtyHarness {
|
|
spawn_polling_session_with_env(content, oauth_user, &[])
|
|
}
|
|
|
|
/// [`spawn_polling_session`] with extra env pairs appended after the shared
|
|
/// oauth/poll env (e.g. a test-seam path or a `TERM_PROGRAM` pin).
|
|
pub(crate) fn spawn_polling_session_with_env(
|
|
content: &ContentController,
|
|
oauth_user: &str,
|
|
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 binary = pager_binary().expect("resolve pager binary");
|
|
let mut harness = PtyHarness::new_in_dir(
|
|
&binary,
|
|
DEFAULT_ROWS,
|
|
DEFAULT_COLS,
|
|
&[],
|
|
&env_refs,
|
|
Some(content.home()),
|
|
)
|
|
.expect("spawn pager with polling session auth");
|
|
|
|
harness
|
|
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
|
|
.expect("welcome text");
|
|
harness
|
|
.inject_keys(format!("{PROMPT}\r").as_bytes())
|
|
.expect("submit prompt to enter session");
|
|
harness
|
|
.wait_for_text(MOCK_RESPONSE_SENTINEL, Duration::from_secs(30))
|
|
.expect("session response");
|
|
harness
|
|
}
|
|
|
|
// ── Agent type mismatch e2e tests ──────────────────────────────────────
|
|
|
|
/// Start the mock server with two models that have different agent types,
|
|
/// and return a `ContentController` configured for agent-type-mismatch
|
|
/// testing. The default model is `"default-model"` (no agent type → uses
|
|
/// `grok-build` harness).
|
|
pub(crate) async fn start_dual_agent_type_content() -> ContentController {
|
|
ContentController::start_with_models(vec![
|
|
MockModel::new("default-model"),
|
|
MockModel::with_agent_type("cursor-model", "cursor"),
|
|
])
|
|
.await
|
|
.expect("start content with dual agent types")
|
|
}
|
|
|
|
// ── Folder-trust welcome sub-state e2e ──────────────────────────────────
|
|
|
|
/// Title line of the folder-trust question (see `render_welcome_trust`).
|
|
pub(crate) const TRUST_QUESTION_SENTINEL: &str = "Do you trust the contents of this directory";
|
|
|
|
/// A `git init`'d temp dir containing a repo-local `.mcp.json` (code-exec
|
|
/// config). `repo_configs_present` returns true for it, so an untrusted clone
|
|
/// with the feature on resolves to `Prompt` => the trust question renders.
|
|
pub(crate) fn git_repo_with_mcp_json() -> tempfile::TempDir {
|
|
let repo = tempfile::tempdir().expect("repo tempdir");
|
|
git2::Repository::init(repo.path()).expect("git init");
|
|
std::fs::write(repo.path().join(".mcp.json"), "{}").expect("write .mcp.json");
|
|
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
|
|
}
|
|
|
|
/// Whether the isolated trust store has recorded a grant for `repo`'s workspace.
|
|
pub(crate) fn folder_is_trusted(content: &ContentController, repo: &std::path::Path) -> bool {
|
|
let store_path = content
|
|
.home()
|
|
.join(".grok")
|
|
.join(xai_grok_workspace::trust::TRUST_FILE_NAME);
|
|
let store = xai_grok_workspace::trust::TrustStore::load_from(store_path);
|
|
store.is_trusted(&xai_grok_workspace::trust::workspace_key(repo))
|
|
}
|
|
|
|
// ── Leader mode e2e ─────────────────────────────────────────────────────
|
|
// The leader cluster cases (and their LEADER_TIMEOUT/STREAM_TIMEOUT/
|
|
// submit_turn/inference_request_count helpers) moved to the dedicated
|
|
// `tests/leader_pty_e2e` target; only the helpers non-leader tests still
|
|
// use remain here.
|
|
|
|
/// Sentinel for turn `n`, short enough to never wrap at 120 cols
|
|
/// (wrapping would break the exactly-once occurrence counts).
|
|
pub(crate) fn turn_sentinel(n: u8) -> String {
|
|
format!("{MOCK_RESPONSE_SENTINEL}_T{n}")
|
|
}
|
|
|
|
// `wait_for_labels_absent` lives in `xai_grok_pager_pty_harness::flows`
|
|
// (re-exported above).
|
|
|
|
// ── MCP menu loading e2e tests ──────────────────────────────────────────
|
|
|
|
/// Seeded server name; it only renders once the MCP list fetch resolves.
|
|
pub(crate) const MCP_TEST_SERVER: &str = "ptytestmcp";
|
|
|
|
/// Budget for session creation plus the `x.ai/mcp/list` round-trip.
|
|
pub(crate) const MCP_MENU_LOAD_TIMEOUT: Duration = Duration::from_secs(30);
|
|
|
|
/// Configured servers list with a status badge even when never connected.
|
|
pub(crate) fn seed_mcp_server_config(content: &ContentController) {
|
|
#[cfg(not(windows))]
|
|
let command = "/bin/cat";
|
|
#[cfg(windows)]
|
|
let command = "cmd.exe";
|
|
|
|
let grok_home = content.home().join(".grok");
|
|
std::fs::create_dir_all(&grok_home).expect("create fake GROK_HOME");
|
|
let config = format!(
|
|
"[mcp_servers.{MCP_TEST_SERVER}]\ncommand = \"{command}\"\nargs = []\nstartup_timeout_sec = 2\n"
|
|
);
|
|
std::fs::write(grok_home.join("config.toml"), config).expect("write config.toml");
|
|
}
|
|
|
|
/// Spawn the pager in `cwd`, open `/mcps`, wait for the seeded server.
|
|
pub(crate) async fn drive_mcp_menu_load(content: &ContentController, cwd: &std::path::Path) {
|
|
seed_mcp_server_config(content);
|
|
|
|
let binary = pager_binary().expect("resolve pager binary");
|
|
let mut harness = PtyHarness::spawn_with_content_in_dir(
|
|
&binary,
|
|
DEFAULT_ROWS,
|
|
DEFAULT_COLS,
|
|
content,
|
|
&[],
|
|
Some(cwd),
|
|
)
|
|
.expect("spawn pager");
|
|
|
|
harness
|
|
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
|
|
.expect("welcome text");
|
|
|
|
// Typing `/` promotes the welcome screen into a new session first.
|
|
harness.inject_keys(b"/mcps\r").expect("submit /mcps");
|
|
|
|
// Tab chrome renders even while loading, so this gate isolates setup failures.
|
|
harness
|
|
.wait_for_text("MCP Servers", Duration::from_secs(15))
|
|
.expect("extensions modal open on MCP Servers tab");
|
|
|
|
harness
|
|
.wait_for_text(MCP_TEST_SERVER, MCP_MENU_LOAD_TIMEOUT)
|
|
.expect("MCP server list loaded in menu");
|
|
|
|
assert!(
|
|
!harness.contains_text("panicked"),
|
|
"pager panicked\nscreen:\n{}",
|
|
harness.screen_contents()
|
|
);
|
|
|
|
harness.quit().expect("clean quit");
|
|
}
|
|
|
|
// ── Queue + interjection e2e tests ──────────────────────────────────────
|
|
|
|
// Ctrl+Enter / Ctrl+; as CSI-u, parsed without kitty protocol negotiation.
|
|
pub(crate) const CTRL_ENTER: &[u8] = b"\x1b[13;5u";
|
|
|
|
pub(crate) const CTRL_SEMICOLON: &[u8] = b"\x1b[59;5u";
|
|
|
|
/// Wire prefix the shell puts on interjected messages.
|
|
pub(crate) const INTERJECTION_WIRE_PREFIX: &str = "The user sent a message while you were working";
|
|
|
|
/// ~5s of paced streaming so the test can type during the turn.
|
|
pub(crate) fn slow_turn_text(sentinel: &str) -> String {
|
|
let mut s = String::from(sentinel);
|
|
for i in 0..30 {
|
|
s.push_str(&format!(" streaming{i}"));
|
|
}
|
|
s
|
|
}
|
|
|
|
/// All user-message contents across every recorded request, in order.
|
|
pub(crate) fn all_user_messages(content: &ContentController) -> Vec<String> {
|
|
content
|
|
.request_bodies()
|
|
.iter()
|
|
.flat_map(|b| {
|
|
b["messages"]
|
|
.as_array()
|
|
.into_iter()
|
|
.flatten()
|
|
.filter(|m| m["role"] == "user")
|
|
.map(|m| m["content"].as_str().unwrap_or_default().to_owned())
|
|
.collect::<Vec<_>>()
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Visible screen lines showing `text` INSIDE the bordered composer (the
|
|
/// prompt-box row carries a `│` border; committed scrollback lines don't).
|
|
/// Keep needles short enough not to wrap at [`DEFAULT_COLS`].
|
|
pub(crate) fn composer_holds(harness: &PtyHarness, text: &str) -> bool {
|
|
harness
|
|
.screen_contents()
|
|
.lines()
|
|
.any(|l| l.contains('│') && l.contains(text))
|
|
}
|
|
|
|
/// Count of visible screen lines showing `text` OUTSIDE the bordered
|
|
/// composer — committed scrollback copies. The exactly-once ledger for the
|
|
/// cancel/rewind duplicate-render regressions.
|
|
pub(crate) fn block_lines_containing(harness: &PtyHarness, text: &str) -> usize {
|
|
harness
|
|
.screen_contents()
|
|
.lines()
|
|
.filter(|l| l.contains(text) && !l.contains('│'))
|
|
.count()
|
|
}
|
|
|
|
/// 19b. **VS Code family: Ctrl+L (form feed)** is the send-now chord, same
|
|
/// semantics as the default Ctrl+Enter binding. Harness strips `TERM_PROGRAM`
|
|
/// then applies env — pass `vscode` so defaults bind the chord to Ctrl+L.
|
|
pub(crate) const CTRL_L: &[u8] = b"\x0c";
|
|
|
|
/// Ctrl+O (C0 0x0F). On Apple Terminal this is the InterjectPrompt / send-now
|
|
/// chord; in minimal mode it also doubles as the transcript-pager remap when
|
|
/// interject would no-op.
|
|
pub(crate) const CTRL_O: &[u8] = b"\x0f";
|
|
|
|
/// Suffix of the mid-turn send-now tip: `Queued · Enter to send now` (or the
|
|
/// interject chord in multiline). Chord-agnostic like [`UNDO_TIP_SENTINEL`].
|
|
pub(crate) const SEND_NOW_TIP_SENTINEL: &str = "to send now";
|
|
|
|
// NOTE: The SessionStart hook exactly-once e2e test is deferred.
|
|
// The core fix (deduplication in load_hooks_from_sources) is verified by
|
|
// unit tests in xai-grok-hooks::discovery::tests. The PTY E2E test requires
|
|
// careful environment variable setup to avoid static caching issues with
|
|
// GROK_HOME.
|
|
|
|
// ── Mouse reporting toggle (opt-in scrollback Ctrl+R) ───────────────────
|
|
|
|
/// Sticky banner shown while mouse reporting is off (must match pager copy).
|
|
pub(crate) const MOUSE_OFF_STICKY: &str =
|
|
"Ctrl+r to enable mouse reporting and restore TUI features";
|
|
|
|
/// Prompt-focused form of the same sticky (swap at render time in `active_toast_message`).
|
|
pub(crate) const MOUSE_OFF_HINT_PROMPT: &str =
|
|
"/toggle-mouse-reporting to enable mouse reporting and restore TUI features";
|
|
|
|
/// Seed `~/.grok/config.toml` with a `[ui]` section body (e.g.
|
|
/// `"vim_mode = true"`). Same `{GROK_HOME|HOME}/.grok/config.toml` location
|
|
/// `seed_mouse_reporting_toggle_config` uses; call before spawning the pager.
|
|
pub(crate) fn seed_ui_config(content: &ContentController, ui_body: &str) {
|
|
let grok_home = content.home().join(".grok");
|
|
std::fs::create_dir_all(&grok_home).expect("create .grok");
|
|
let config = format!("[ui]\n{ui_body}\n");
|
|
std::fs::write(grok_home.join("config.toml"), config).expect("write config.toml");
|
|
}
|
|
|
|
pub(crate) fn seed_mouse_reporting_toggle_config(content: &ContentController, enabled: bool) {
|
|
let grok_home = content.home().join(".grok");
|
|
std::fs::create_dir_all(&grok_home).expect("create .grok");
|
|
// Minimal opt-in only — matches load_config's `{GROK_HOME|HOME}/.grok/config.toml`.
|
|
let config = if enabled {
|
|
"[ui]\nmouse_reporting_toggle = true\n"
|
|
} else {
|
|
// Minimal config so HOME layout matches the enabled case; toggle stays off.
|
|
"[ui]\n"
|
|
};
|
|
std::fs::write(grok_home.join("config.toml"), config).expect("write config.toml");
|
|
}
|
|
|
|
/// Seed `[ui] keep_text_selection = "hold"` under the content controller's home.
|
|
pub(crate) fn seed_keep_text_selection_config(content: &ContentController) {
|
|
let grok_home = content.home().join(".grok");
|
|
std::fs::create_dir_all(&grok_home).expect("create .grok");
|
|
std::fs::write(
|
|
grok_home.join("config.toml"),
|
|
"[ui]\nkeep_text_selection = \"hold\"\n",
|
|
)
|
|
.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).
|
|
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")
|
|
}
|
|
|
|
/// Inject keys one byte at a time with a short drain between each so the pager
|
|
/// event loop processes them as discrete key events. Bulk injects (especially
|
|
/// post-turn when non-dev builds are not metronoming on `tracing_rx`) can arrive
|
|
/// in a single `EventStream` batch and get paste-coalesced (`[Pasted: N lines]`),
|
|
/// which never reaches slash submit.
|
|
pub(crate) fn inject_keys_paced(harness: &mut PtyHarness, keys: &[u8]) {
|
|
for &b in keys {
|
|
harness.inject_keys(&[b]).expect("inject paced key");
|
|
harness.update(Duration::from_millis(50));
|
|
}
|
|
}
|
|
|
|
/// Widens the pager's idle-Esc double-press window (bounded by
|
|
/// `esc_double_press_ttl` in `app_view.rs`) so a loaded shard's inter-press
|
|
/// render round-trip can't expire the arm.
|
|
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")
|
|
}
|
|
|
|
/// Reach an agent session with scrollback content, then focus scrollback (Tab).
|
|
pub(crate) async fn drive_to_scrollback_with_turn(
|
|
harness: &mut PtyHarness,
|
|
content: &ContentController,
|
|
) {
|
|
content.set_response(format!("{MOCK_RESPONSE_SENTINEL} mouse toggle turn."));
|
|
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(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).
|
|
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).
|
|
let _ = harness.wait_for_text("Space:prompt", Duration::from_secs(5));
|
|
}
|
|
|
|
// ── Tool header selection (path/command operand only) ───────────────────
|
|
|
|
/// Unique filename marker so the Read header is unambiguous on screen.
|
|
pub(crate) const READ_HDR_FILE: &str = "read_hdr_sel_target.txt";
|
|
|
|
pub(crate) const READ_HDR_SENTINEL: &str = "READ_HDR_SEL_DONE";
|
|
|
|
/// Responses API SSE stream that emits a single `function_call` tool invoke.
|
|
pub(crate) fn responses_api_tool_call_events(
|
|
call_id: &str,
|
|
name: &str,
|
|
arguments: &str,
|
|
) -> Vec<SseEvent> {
|
|
let mut events = Vec::new();
|
|
let mut seq = 0u64;
|
|
events.push(SseEvent::data(
|
|
json!({
|
|
"type": "response.created",
|
|
"sequence_number": seq,
|
|
"response": {
|
|
"id": "resp_read_hdr",
|
|
"object": "response",
|
|
"created_at": 1234567890,
|
|
"model": "test-model",
|
|
"status": "in_progress",
|
|
"output": []
|
|
}
|
|
})
|
|
.to_string(),
|
|
));
|
|
seq += 1;
|
|
events.push(SseEvent::data(
|
|
json!({
|
|
"type": "response.function_call_arguments.delta",
|
|
"sequence_number": seq,
|
|
"item_id": call_id,
|
|
"output_index": 0,
|
|
"delta": arguments
|
|
})
|
|
.to_string(),
|
|
));
|
|
seq += 1;
|
|
events.push(SseEvent::data(
|
|
json!({
|
|
"type": "response.completed",
|
|
"sequence_number": seq,
|
|
"response": {
|
|
"id": "resp_read_hdr",
|
|
"object": "response",
|
|
"created_at": 1234567890,
|
|
"model": "test-model",
|
|
"status": "completed",
|
|
"output": [{
|
|
"type": "function_call",
|
|
"call_id": call_id,
|
|
"name": name,
|
|
"arguments": arguments
|
|
}],
|
|
"usage": {
|
|
"input_tokens": 10,
|
|
"output_tokens": 20,
|
|
"total_tokens": 30,
|
|
"input_tokens_details": { "cached_tokens": 0 },
|
|
"output_tokens_details": { "reasoning_tokens": 0 }
|
|
}
|
|
}
|
|
})
|
|
.to_string(),
|
|
));
|
|
events.push(SseEvent::data("[DONE]".to_string()));
|
|
events
|
|
}
|
|
|
|
/// Chat Completions SSE stream with a single tool_call (fallback endpoint).
|
|
pub(crate) fn chat_completions_tool_call_events(name: &str, arguments: &str) -> Vec<SseEvent> {
|
|
chat_completions_tool_call_events_with_id("call_read_hdr", name, arguments)
|
|
}
|
|
|
|
/// [`chat_completions_tool_call_events`] with an explicit `tool_call` id, for
|
|
/// tests scripting several calls into ONE conversation (a reused id would
|
|
/// alias distinct calls in history and confuse dangling-call bookkeeping).
|
|
pub(crate) fn chat_completions_tool_call_events_with_id(
|
|
call_id: &str,
|
|
name: &str,
|
|
arguments: &str,
|
|
) -> Vec<SseEvent> {
|
|
let tool_calls = vec![json!({
|
|
"index": 0,
|
|
"id": call_id,
|
|
"type": "function",
|
|
"function": { "name": name, "arguments": arguments }
|
|
})];
|
|
vec![
|
|
SseEvent::data(
|
|
json!({
|
|
"id": "chatcmpl-read-hdr",
|
|
"object": "chat.completion.chunk",
|
|
"created": 1234567890,
|
|
"model": "test-model",
|
|
"choices": [{
|
|
"index": 0,
|
|
"delta": {
|
|
"role": "assistant",
|
|
"content": null,
|
|
"tool_calls": tool_calls
|
|
},
|
|
"finish_reason": null
|
|
}]
|
|
})
|
|
.to_string(),
|
|
),
|
|
SseEvent::data(
|
|
json!({
|
|
"id": "chatcmpl-read-hdr",
|
|
"object": "chat.completion.chunk",
|
|
"created": 1234567890,
|
|
"model": "test-model",
|
|
"choices": [{
|
|
"index": 0,
|
|
"delta": {},
|
|
"finish_reason": "tool_calls"
|
|
}],
|
|
"usage": {
|
|
"prompt_tokens": 10,
|
|
"completion_tokens": 20,
|
|
"total_tokens": 30
|
|
}
|
|
})
|
|
.to_string(),
|
|
),
|
|
SseEvent::data("[DONE]".to_string()),
|
|
]
|
|
}
|
|
|
|
/// Responses API SSE stream that emits a single assistant text message —
|
|
/// the FIFO counterpart of `set_response` for tests scripting DISTINCT text
|
|
/// replies per turn (e.g. one per auto-wake).
|
|
pub(crate) fn responses_api_message_events(text: &str) -> Vec<SseEvent> {
|
|
vec![
|
|
SseEvent::data(
|
|
json!({
|
|
"type": "response.created",
|
|
"sequence_number": 0,
|
|
"response": {
|
|
"id": "resp_text",
|
|
"object": "response",
|
|
"created_at": 1234567890,
|
|
"model": "test-model",
|
|
"status": "in_progress",
|
|
"output": []
|
|
}
|
|
})
|
|
.to_string(),
|
|
),
|
|
SseEvent::data(
|
|
json!({
|
|
"type": "response.output_text.delta",
|
|
"sequence_number": 1,
|
|
"item_id": "item_text",
|
|
"output_index": 0,
|
|
"content_index": 0,
|
|
"delta": text
|
|
})
|
|
.to_string(),
|
|
),
|
|
SseEvent::data(
|
|
json!({
|
|
"type": "response.completed",
|
|
"sequence_number": 2,
|
|
"response": {
|
|
"id": "resp_text",
|
|
"object": "response",
|
|
"created_at": 1234567890,
|
|
"model": "test-model",
|
|
"status": "completed",
|
|
"output": [{
|
|
"type": "message",
|
|
"id": "msg_text",
|
|
"role": "assistant",
|
|
"status": "completed",
|
|
"content": [{
|
|
"type": "output_text",
|
|
"text": text,
|
|
"annotations": []
|
|
}]
|
|
}],
|
|
"usage": {
|
|
"input_tokens": 10,
|
|
"output_tokens": 10,
|
|
"total_tokens": 20,
|
|
"input_tokens_details": { "cached_tokens": 0 },
|
|
"output_tokens_details": { "reasoning_tokens": 0 }
|
|
}
|
|
}
|
|
})
|
|
.to_string(),
|
|
),
|
|
SseEvent::data("[DONE]".to_string()),
|
|
]
|
|
}
|
|
|
|
/// Chat Completions SSE stream with a single assistant text message
|
|
/// (fallback endpoint counterpart of [`responses_api_message_events`]).
|
|
pub(crate) fn chat_completions_message_events(text: &str) -> Vec<SseEvent> {
|
|
vec![
|
|
SseEvent::data(
|
|
json!({
|
|
"id": "chatcmpl-text",
|
|
"object": "chat.completion.chunk",
|
|
"created": 1234567890,
|
|
"model": "test-model",
|
|
"choices": [{
|
|
"index": 0,
|
|
"delta": { "role": "assistant", "content": text },
|
|
"finish_reason": "stop"
|
|
}],
|
|
"usage": {
|
|
"prompt_tokens": 10,
|
|
"completion_tokens": 10,
|
|
"total_tokens": 20
|
|
}
|
|
})
|
|
.to_string(),
|
|
),
|
|
SseEvent::data("[DONE]".to_string()),
|
|
]
|
|
}
|
|
|
|
/// Poll the raw PTY stream until at least one OSC 52 clipboard payload has
|
|
/// been flushed (or `timeout` elapses), then return everything decoded so
|
|
/// far. A copy lands asynchronously after the triggering input, so a fixed
|
|
/// post-release sleep flakes under CI/host load.
|
|
pub(crate) fn wait_for_osc52_payloads(harness: &mut PtyHarness, timeout: Duration) -> Vec<String> {
|
|
let deadline = Instant::now() + timeout;
|
|
loop {
|
|
harness.update(Duration::from_millis(200));
|
|
let payloads = decode_osc52_payloads(harness.raw_output());
|
|
if !payloads.is_empty() || Instant::now() >= deadline {
|
|
return payloads;
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(crate) fn decode_osc52_payloads(bytes: &[u8]) -> Vec<String> {
|
|
use base64::Engine as _;
|
|
let output = String::from_utf8_lossy(bytes);
|
|
let mut payloads = Vec::new();
|
|
for segment in output.split("\x1b]52;").skip(1) {
|
|
let Some((_, rest)) = segment.split_once(';') else {
|
|
continue;
|
|
};
|
|
// No BEL/ST terminator yet = mid-flush tail; skip it so the poll in
|
|
// wait_for_osc52_payloads waits for the complete payload instead of
|
|
// decoding a truncated-but-valid base64 quantum.
|
|
let Some(end) = rest.find(['\x07', '\x1b']) else {
|
|
continue;
|
|
};
|
|
let encoded = &rest[..end];
|
|
if encoded.is_empty() {
|
|
continue;
|
|
}
|
|
if let Ok(decoded) = base64::engine::general_purpose::STANDARD.decode(encoded)
|
|
&& let Ok(text) = String::from_utf8(decoded)
|
|
{
|
|
payloads.push(text);
|
|
}
|
|
}
|
|
payloads
|
|
}
|
|
|
|
/// One SGR (DECSET 1006) mouse report for button `btn` at 0-based (row,col),
|
|
/// emitted as the 1-based SGR wire encoding:
|
|
/// `suffix` 'M' = press/motion, 'm' = release; `btn` carries the +32 motion bit / +64 wheel bit.
|
|
/// Encoding spec: https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h2-Mouse-Tracking
|
|
pub(crate) fn sgr_mouse(btn: u16, row: u16, col: u16, suffix: char) -> String {
|
|
format!("\x1b[<{btn};{};{}{suffix}", col + 1, row + 1)
|
|
}
|
|
|
|
/// SGR mouse drag from (row,col) inclusive to (row,end_col) inclusive.
|
|
pub(crate) fn mouse_drag_line(row: u16, from_col: u16, to_col: u16) -> String {
|
|
let mut out = String::new();
|
|
out.push_str(&sgr_mouse(0, row, from_col, 'M')); // press
|
|
out.push_str(&sgr_mouse(32, row, (from_col + to_col) / 2, 'M')); // drag mid
|
|
out.push_str(&sgr_mouse(32, row, to_col, 'M')); // drag end
|
|
out.push_str(&sgr_mouse(0, row, to_col, 'm')); // release
|
|
out
|
|
}
|
|
|
|
/// SGR mouse press + drag from (row,from_col) to (row,to_col) inclusive with no
|
|
/// final release — reproduces a lost `Up(Left)` so the drag stays latched. Real
|
|
/// terminals drop the release this way when the mouseup lands off the terminal
|
|
/// element (or is coalesced/dropped over Remote-SSH): xtermjs/xterm.js#4781
|
|
/// ("It works if mouseup occurs outside the terminal element"), microsoft/vscode#192518.
|
|
pub(crate) fn mouse_drag_no_release(row: u16, from_col: u16, to_col: u16) -> String {
|
|
let mut out = String::new();
|
|
out.push_str(&sgr_mouse(0, row, from_col, 'M')); // press
|
|
out.push_str(&sgr_mouse(32, row, (from_col + to_col) / 2, 'M')); // drag mid
|
|
out.push_str(&sgr_mouse(32, row, to_col, 'M')); // drag end
|
|
out
|
|
}
|
|
|
|
pub(crate) fn locate_screen_text(screen: &str, needle: &str) -> Option<(u16, u16)> {
|
|
for (row, line) in screen.lines().enumerate() {
|
|
if let Some(byte) = line.find(needle) {
|
|
let col = line[..byte].chars().count() as u16;
|
|
return Some((row as u16, col));
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
/// Queue one scripted tool-call turn on both inference endpoints (only the
|
|
/// endpoint the agent actually uses drains its FIFO; the other stays parked).
|
|
pub(crate) fn enqueue_tool_turn(
|
|
content: &ContentController,
|
|
call_id: &str,
|
|
name: &str,
|
|
args: String,
|
|
) {
|
|
content.enqueue_response(
|
|
"/v1/responses",
|
|
ScriptedResponse::sse(responses_api_tool_call_events(call_id, name, &args)),
|
|
);
|
|
content.enqueue_response(
|
|
"/v1/chat/completions",
|
|
ScriptedResponse::sse(chat_completions_tool_call_events(name, &args)),
|
|
);
|
|
}
|
|
|
|
/// Responses API SSE stream whose `response.completed` output carries one
|
|
/// `function_call` item per entry of `calls` — a single model turn invoking
|
|
/// parallel tool calls. Each entry is `(call_id, name, arguments)`; ids must
|
|
/// be distinct or history bookkeeping aliases the calls.
|
|
pub(crate) fn responses_api_parallel_tool_call_events(
|
|
calls: &[(&str, &str, String)],
|
|
) -> Vec<SseEvent> {
|
|
let mut events = Vec::new();
|
|
let mut seq = 0u64;
|
|
events.push(SseEvent::data(
|
|
json!({
|
|
"type": "response.created",
|
|
"sequence_number": seq,
|
|
"response": {
|
|
"id": "resp_parallel_tools",
|
|
"object": "response",
|
|
"created_at": 1234567890,
|
|
"model": "test-model",
|
|
"status": "in_progress",
|
|
"output": []
|
|
}
|
|
})
|
|
.to_string(),
|
|
));
|
|
for (i, (call_id, _name, arguments)) in calls.iter().enumerate() {
|
|
seq += 1;
|
|
events.push(SseEvent::data(
|
|
json!({
|
|
"type": "response.function_call_arguments.delta",
|
|
"sequence_number": seq,
|
|
"item_id": call_id,
|
|
"output_index": i,
|
|
"delta": arguments
|
|
})
|
|
.to_string(),
|
|
));
|
|
}
|
|
seq += 1;
|
|
let output: Vec<serde_json::Value> = calls
|
|
.iter()
|
|
.map(|(call_id, name, arguments)| {
|
|
json!({
|
|
"type": "function_call",
|
|
"call_id": call_id,
|
|
"name": name,
|
|
"arguments": arguments
|
|
})
|
|
})
|
|
.collect();
|
|
events.push(SseEvent::data(
|
|
json!({
|
|
"type": "response.completed",
|
|
"sequence_number": seq,
|
|
"response": {
|
|
"id": "resp_parallel_tools",
|
|
"object": "response",
|
|
"created_at": 1234567890,
|
|
"model": "test-model",
|
|
"status": "completed",
|
|
"output": output,
|
|
"usage": {
|
|
"input_tokens": 10,
|
|
"output_tokens": 20,
|
|
"total_tokens": 30,
|
|
"input_tokens_details": { "cached_tokens": 0 },
|
|
"output_tokens_details": { "reasoning_tokens": 0 }
|
|
}
|
|
}
|
|
})
|
|
.to_string(),
|
|
));
|
|
events.push(SseEvent::data("[DONE]".to_string()));
|
|
events
|
|
}
|
|
|
|
/// Chat Completions twin of [`responses_api_parallel_tool_call_events`]: one
|
|
/// chunk whose `delta.tool_calls` carries every call (index 0, 1, …), then a
|
|
/// `finish_reason: "tool_calls"` chunk.
|
|
pub(crate) fn chat_completions_parallel_tool_call_events(
|
|
calls: &[(&str, &str, String)],
|
|
) -> Vec<SseEvent> {
|
|
let tool_calls: Vec<serde_json::Value> = calls
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(i, (call_id, name, arguments))| {
|
|
json!({
|
|
"index": i,
|
|
"id": call_id,
|
|
"type": "function",
|
|
"function": { "name": name, "arguments": arguments }
|
|
})
|
|
})
|
|
.collect();
|
|
vec![
|
|
SseEvent::data(
|
|
json!({
|
|
"id": "chatcmpl-parallel-tools",
|
|
"object": "chat.completion.chunk",
|
|
"created": 1234567890,
|
|
"model": "test-model",
|
|
"choices": [{
|
|
"index": 0,
|
|
"delta": {
|
|
"role": "assistant",
|
|
"content": null,
|
|
"tool_calls": tool_calls
|
|
},
|
|
"finish_reason": null
|
|
}]
|
|
})
|
|
.to_string(),
|
|
),
|
|
SseEvent::data(
|
|
json!({
|
|
"id": "chatcmpl-parallel-tools",
|
|
"object": "chat.completion.chunk",
|
|
"created": 1234567890,
|
|
"model": "test-model",
|
|
"choices": [{
|
|
"index": 0,
|
|
"delta": {},
|
|
"finish_reason": "tool_calls"
|
|
}],
|
|
"usage": {
|
|
"prompt_tokens": 10,
|
|
"completion_tokens": 20,
|
|
"total_tokens": 30
|
|
}
|
|
})
|
|
.to_string(),
|
|
),
|
|
SseEvent::data("[DONE]".to_string()),
|
|
]
|
|
}
|
|
|
|
/// Queue one scripted turn with parallel tool calls on both inference
|
|
/// endpoints (see [`enqueue_tool_turn`]).
|
|
pub(crate) fn enqueue_parallel_tool_turn(
|
|
content: &ContentController,
|
|
calls: &[(&str, &str, String)],
|
|
) {
|
|
content.enqueue_response(
|
|
"/v1/responses",
|
|
ScriptedResponse::sse(responses_api_parallel_tool_call_events(calls)),
|
|
);
|
|
content.enqueue_response(
|
|
"/v1/chat/completions",
|
|
ScriptedResponse::sse(chat_completions_parallel_tool_call_events(calls)),
|
|
);
|
|
}
|
|
|
|
/// Seed a target file under the isolated HOME and queue a scripted `read_file`
|
|
/// tool call (Responses + Chat Completions) so the pager renders a Read header.
|
|
pub(crate) fn seed_read_file_tool_call(content: &ContentController, abs_path: &Path) {
|
|
let args = json!({ "target_file": abs_path.to_string_lossy() }).to_string();
|
|
// Prefer Responses API (primary agent path); also queue Chat Completions.
|
|
content.enqueue_response(
|
|
"/v1/responses",
|
|
ScriptedResponse::sse(responses_api_tool_call_events(
|
|
"call_read_hdr",
|
|
"read_file",
|
|
&args,
|
|
)),
|
|
);
|
|
content.enqueue_response(
|
|
"/v1/chat/completions",
|
|
ScriptedResponse::sse(chat_completions_tool_call_events("read_file", &args)),
|
|
);
|
|
// Follow-up turn after tool result: plain completion so the session settles.
|
|
content.set_response(READ_HDR_SENTINEL);
|
|
}
|
|
|
|
// ── Minimal (scrollback-native) mode e2e helpers ────────────────────────
|
|
|
|
/// Args that launch the pager in the experimental scrollback-native minimal
|
|
/// mode, standalone — minimal is single-session (K14: no leader/multi-client),
|
|
/// so `--no-leader` keeps the test off the shared-daemon path.
|
|
pub(crate) const MINIMAL_ARGS: &[&str] = &["--minimal", "--no-leader"];
|
|
|
|
/// Idle status-line text minimal renders at the prompt (see
|
|
/// `crate::minimal::live::render_status`). Distinct from the running status
|
|
/// (`working…`), so it doubles as a "ready / turn finished" sentinel.
|
|
pub(crate) const MINIMAL_IDLE_SENTINEL: &str = "minimal · /help";
|
|
|
|
/// Idle status after a slash `/minimal` re-exec (switch-back cue present).
|
|
/// Distinct from [`MINIMAL_IDLE_SENTINEL`] — cold `--minimal` starts omit the
|
|
/// reverse-command segment.
|
|
pub(crate) const MINIMAL_SWITCH_BACK_IDLE_SENTINEL: &str =
|
|
"minimal · /fullscreen to go back · /help";
|
|
|
|
/// Spawn the pager in minimal mode against `content` at the default size.
|
|
pub(crate) fn spawn_minimal(content: &ContentController) -> PtyHarness {
|
|
spawn_minimal_sized(content, DEFAULT_ROWS, DEFAULT_COLS)
|
|
}
|
|
|
|
/// Spawn minimal at an explicit terminal size. A short terminal forces
|
|
/// committed blocks into native scrollback sooner (less static space above the
|
|
/// pinned live region).
|
|
///
|
|
/// Response forwarding is enabled so the inline viewport's startup
|
|
/// cursor-position query is answered — without it, `--minimal` silently
|
|
/// downgrades to full-screen inline (the probe times out) and these tests would
|
|
/// assert against the wrong render path.
|
|
pub(crate) fn spawn_minimal_sized(content: &ContentController, rows: u16, cols: u16) -> PtyHarness {
|
|
let binary = pager_binary().expect("resolve pager binary");
|
|
let mut harness = PtyHarness::spawn_with_content(&binary, rows, cols, content, MINIMAL_ARGS)
|
|
.expect("spawn minimal pager");
|
|
harness.set_respond_to_queries(true);
|
|
harness
|
|
}
|
|
|
|
/// Spawn minimal in an explicit project dir, appending `extra_args` to
|
|
/// [`MINIMAL_ARGS`] (e.g. `--continue`). Sessions are keyed by cwd, so
|
|
/// resume / new-session tests need a stable directory across runs. Query
|
|
/// forwarding is enabled (as in [`spawn_minimal_sized`]) so the inline-viewport
|
|
/// probe completes and minimal does not silently downgrade to full-screen inline.
|
|
pub(crate) fn spawn_minimal_in_dir(
|
|
content: &ContentController,
|
|
rows: u16,
|
|
cols: u16,
|
|
extra_args: &[&str],
|
|
cwd: &Path,
|
|
) -> PtyHarness {
|
|
let binary = pager_binary().expect("resolve pager binary");
|
|
let mut args = MINIMAL_ARGS.to_vec();
|
|
args.extend_from_slice(extra_args);
|
|
let mut harness =
|
|
PtyHarness::spawn_with_content_in_dir(&binary, rows, cols, content, &args, Some(cwd))
|
|
.expect("spawn minimal pager in dir");
|
|
harness.set_respond_to_queries(true);
|
|
harness
|
|
}
|
|
|
|
/// Block until minimal has cold-started into its agent session and is idle at
|
|
/// the prompt (the `minimal · /help` status line is showing). Minimal has no
|
|
/// welcome screen, so this — not [`WELCOME_SCREEN_SENTINEL`] — is the readiness
|
|
/// gate.
|
|
pub(crate) fn wait_minimal_ready(harness: &mut PtyHarness) {
|
|
harness
|
|
.wait_for_text(MINIMAL_IDLE_SENTINEL, WELCOME_TIMEOUT)
|
|
.unwrap_or_else(|e| {
|
|
panic!(
|
|
"minimal never cold-started to an idle prompt: {e}\nscreen:\n{}",
|
|
harness.screen_contents()
|
|
)
|
|
});
|
|
}
|
|
|
|
/// Quit minimal cleanly. The prompt is always focused (a bare `q` would type
|
|
/// into it), so quit is Ctrl+Q pressed twice (it requires confirmation). Falls
|
|
/// back to the harness kill path if the chord doesn't take.
|
|
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
|
|
}
|
|
}
|
|
|
|
// ── grok wrap e2e ───────────────────────────────────────────────────────
|
|
|
|
/// `grok wrap` run budget. Same contention math as the requirements-version
|
|
/// test: the child's cold exec of the huge debug binary can land its first
|
|
/// write well past 30s under the parallel pty_e2e suite.
|
|
#[cfg(unix)]
|
|
pub(crate) const WRAP_TIMEOUT: Duration = Duration::from_secs(120);
|
|
|
|
#[cfg(unix)]
|
|
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
|
|
/// [`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.
|
|
#[cfg(unix)]
|
|
pub(crate) fn run_wrap(wrap_args: &[&str], extra_env: &[(&str, &str)]) -> (Option<u32>, String) {
|
|
let binary = pager_binary().expect("resolve pager binary");
|
|
let home = tempfile::tempdir().expect("home tempdir");
|
|
let home_str = home.path().to_str().expect("utf8 home").to_owned();
|
|
|
|
let mut args = vec!["wrap"];
|
|
args.extend_from_slice(wrap_args);
|
|
let mut env: Vec<(&str, &str)> = vec![("GROK_HOME", &home_str), ("NO_COLOR", "1")];
|
|
env.extend_from_slice(extra_env);
|
|
|
|
let mut harness =
|
|
PtyHarness::new(&binary, DEFAULT_ROWS, DEFAULT_COLS, &args, &env).expect("spawn grok wrap");
|
|
|
|
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 raw = String::from_utf8_lossy(harness.raw_output()).into_owned();
|
|
(code, raw)
|
|
}
|
|
|
|
/// Write an executable fake `$SHELL` that prints each argv element on its own
|
|
/// `ARG:`-prefixed line and exits 0, so tests can assert the exact argv
|
|
/// `grok wrap` hands to the user's shell without depending on any real
|
|
/// shell's rc files or alias state. Keep the returned tempdir alive for the
|
|
/// duration of the run.
|
|
#[cfg(unix)]
|
|
pub(crate) fn fake_argv_echo_shell() -> (tempfile::TempDir, String) {
|
|
use std::os::unix::fs::PermissionsExt;
|
|
|
|
let dir = tempfile::tempdir().expect("fake shell tempdir");
|
|
let path = dir.path().join("fakeshell");
|
|
std::fs::write(
|
|
&path,
|
|
"#!/bin/sh\nfor a in \"$@\"; do printf 'ARG:%s\\n' \"$a\"; done\n",
|
|
)
|
|
.expect("write fake shell");
|
|
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755))
|
|
.expect("chmod fake shell");
|
|
let path_str = path.to_str().expect("utf8 fake shell path").to_owned();
|
|
(dir, path_str)
|
|
}
|
|
|
|
// ── Shared polling / failure-dump / cast helpers ────────────────────────
|
|
|
|
/// Poll `probe` every 100ms until it yields `Some` or `timeout` elapses.
|
|
#[cfg(unix)]
|
|
pub(crate) fn poll_for<T>(timeout: Duration, mut probe: impl FnMut() -> Option<T>) -> Option<T> {
|
|
let deadline = Instant::now() + timeout;
|
|
loop {
|
|
if let Some(v) = probe() {
|
|
return Some(v);
|
|
}
|
|
if Instant::now() >= deadline {
|
|
return None;
|
|
}
|
|
std::thread::sleep(Duration::from_millis(100));
|
|
}
|
|
}
|
|
|
|
/// Poll `cond` every 100ms until it returns true or `timeout` elapses.
|
|
#[cfg(unix)]
|
|
pub(crate) fn wait_until(timeout: Duration, mut cond: impl FnMut() -> bool) -> bool {
|
|
poll_for(timeout, || cond().then_some(())).is_some()
|
|
}
|
|
|
|
/// Dump assistant/tool/user messages (skipping the huge system prompt) from
|
|
/// every request body, to inspect tool args / tool results / user queries in
|
|
/// a failure message without printing megabytes of `{:#?}` bodies.
|
|
#[cfg(unix)]
|
|
pub(crate) fn dump_non_system_messages(bodies: &[serde_json::Value]) -> String {
|
|
let mut out = String::new();
|
|
for (i, b) in bodies.iter().enumerate() {
|
|
let msgs = b.get("messages").or_else(|| b.get("input"));
|
|
let Some(arr) = msgs.and_then(|m| m.as_array()) else {
|
|
continue;
|
|
};
|
|
for m in arr {
|
|
let role = m.get("role").and_then(|r| r.as_str()).unwrap_or("?");
|
|
if role == "system" {
|
|
continue;
|
|
}
|
|
let s = serde_json::to_string(m).unwrap_or_default();
|
|
out.push_str(&format!(
|
|
"[req{i} {role}] {}\n",
|
|
s.chars().take(900).collect::<String>()
|
|
));
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Extract the runtime task id from a serialized request body containing the
|
|
/// background-start tool result's `<task-id>…</task-id>` envelope.
|
|
#[cfg(unix)]
|
|
pub(crate) fn extract_task_id(body: &str) -> Option<String> {
|
|
let start = body.find("<task-id>")? + "<task-id>".len();
|
|
let rest = &body[start..];
|
|
let end = rest.find("</task-id>")?;
|
|
let id = rest[..end].trim();
|
|
(!id.is_empty()).then(|| id.to_string())
|
|
}
|
|
|
|
/// Dump an asciinema cast of `harness` into `$GROK_PTY_CAST_DIR/<file_name>`
|
|
/// when the env var is set. Failures are logged, never fatal — the cast is a
|
|
/// diagnostic artifact, not part of the assertion surface.
|
|
#[cfg(unix)]
|
|
pub(crate) fn write_cast_if_requested(harness: &PtyHarness, file_name: &str) {
|
|
let Ok(dir) = std::env::var("GROK_PTY_CAST_DIR") else {
|
|
return;
|
|
};
|
|
if dir.is_empty() {
|
|
return;
|
|
}
|
|
let path = std::path::PathBuf::from(dir).join(file_name);
|
|
match harness.write_cast(&path) {
|
|
Ok(()) => eprintln!("wrote cast: {}", path.display()),
|
|
Err(e) => eprintln!("failed to write cast {}: {e}", path.display()),
|
|
}
|
|
}
|
|
|
|
// ── Clipboard paste e2e tests ───────────────────────────────────────────
|
|
|
|
/// Serialized `content` of every user message across recorded requests, in
|
|
/// order. Like [`all_user_messages`] but multimodal-tolerant: array-form
|
|
/// content (e.g. text plus an image block attached from a macOS dev machine's
|
|
/// real clipboard during a paste test) serializes to its JSON string instead
|
|
/// of vanishing, so contains-style sentinel asserts keep working.
|
|
pub(crate) fn all_user_message_blobs(content: &ContentController) -> Vec<String> {
|
|
content
|
|
.request_bodies()
|
|
.iter()
|
|
.flat_map(|b| {
|
|
// Chat Completions carries `messages`; the Responses shape carries
|
|
// the same role/content layout under `input`.
|
|
let items = b["messages"].as_array().or_else(|| b["input"].as_array());
|
|
items
|
|
.into_iter()
|
|
.flatten()
|
|
.filter(|m| m["role"] == "user")
|
|
.map(|m| match m["content"].as_str() {
|
|
Some(s) => s.to_owned(),
|
|
None => m["content"].to_string(),
|
|
})
|
|
.collect::<Vec<_>>()
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
// The `paste_ctrl_v_*_{macos,windows}` tests drive the REAL host clipboard
|
|
// via the harness's shared `host_clipboard` plumbing (pbcopy/osascript on
|
|
// macOS, PowerShell on Windows), so they are OS-native and mutate the
|
|
// machine-global clipboard; they hold `#[serial_test::serial(host_clipboard)]`
|
|
// so two clipboard tests never interleave within one test process.
|
|
#[cfg(any(target_os = "macos", target_os = "windows"))]
|
|
pub(crate) use xai_grok_pager_pty_harness::host_clipboard::{
|
|
HostClipboardTextGuard, pbcopy, set_clipboard_png, write_fixture_png,
|
|
};
|
|
// Windows CI sessions may lack a usable clipboard; the windows twins gate on
|
|
// 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;
|