//! 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::()); } 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 { 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::>() }) .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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 = 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 { let tool_calls: Vec = 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 ` 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, 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(timeout: Duration, mut probe: impl FnMut() -> Option) -> Option { 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::() )); } } out } /// Extract the runtime task id from a serialized request body containing the /// background-start tool result's `` envelope. #[cfg(unix)] pub(crate) fn extract_task_id(body: &str) -> Option { let start = body.find("")? + "".len(); let rest = &body[start..]; let end = rest.find("")?; let id = rest[..end].trim(); (!id.is_empty()).then(|| id.to_string()) } /// Dump an asciinema cast of `harness` into `$GROK_PTY_CAST_DIR/` /// 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 { 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::>() }) .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;