//! Cross-suite e2e flow helpers over [`PtyHarness`] / [`ContentController`]. //! //! The single canonical home for driving/seeding helpers shared by the //! pager's `pty_e2e` and `leader_pty_e2e` test targets (both depend on this //! crate); suite-local constants (sizes, sentinels, timeouts) stay in each //! suite's `common.rs`. use std::time::{Duration, Instant}; use crate::{ContentController, PtyHarness}; /// Pump PTY output until every label is absent from the visible screen. pub fn wait_for_labels_absent(h: &mut PtyHarness, labels: &[&str], timeout: Duration) { let _ = h.wait_until("screen labels to disappear", timeout, |h| { labels.iter().all(|label| !h.contains_text(label)) }); } /// Submit `prompt` from `h`, then keep re-pressing Enter until the turn /// actually starts streaming (`sentinel` appears) or `timeout` elapses. /// /// In a heavy multi-client leader cluster the driver's submit Enter can be /// dropped when it races the other client attaching / replaying on the shared /// leader: the typed prompt is left sitting unsubmitted in the composer, the /// turn never starts, and a plain `wait_for_text` then times out (the observed /// `leader_two_clients_shared_session` flake — A idle with `again` still in the /// composer at 75s). Re-pressing Enter is safe and idempotent: submitting takes /// the composer draft synchronously (`std::mem::take` in `dispatch`), so once a /// turn has really been sent the composer is empty and an extra Enter is a /// no-op. It can only submit a still-stuck prompt, never double-submit a sent /// one (which would break exactly-once scrollback asserts). pub fn submit_turn(h: &mut PtyHarness, prompt: &str, sentinel: &str, timeout: Duration) { h.inject_keys(format!("{prompt}\r").as_bytes()) .expect("inject prompt submit"); let deadline = Instant::now() + timeout; loop { let remaining = deadline.saturating_duration_since(Instant::now()); // Per-attempt sub-budget, generous enough that a genuinely in-flight // submit resolves before we re-nudge (so the re-nudge only ever fires // on an empty composer, where it is a no-op). if h.wait_for_text(sentinel, Duration::from_secs(10).min(remaining)) .is_ok() { return; } assert!( Instant::now() < deadline, "timed out after {timeout:?} waiting for {sentinel:?}\nscreen:\n{}", h.screen_contents() ); let _ = h.inject_keys(b"\r"); } } /// Count only inference requests (chat completions / responses / messages), /// ignoring incidental GETs like /v1/models and /v1/settings, so a replay /// invariant means "no turn was re-driven" rather than "no HTTP at all". pub fn inference_request_count(content: &ContentController) -> usize { content .requests() .iter() .filter(|e| { e.path.contains("/chat/completions") || e.path.contains("/responses") || e.path.contains("/messages") }) .count() } /// Seed a fake xAI OAuth entry into the isolated home's `auth.json` so the /// shell has session auth (the harness's `XAI_API_KEY` is ApiKey/BYOK mode /// and never enters the auth manager). Load-bearing details: the scope key /// must be `::`, `auth_mode` must be `oidc`, and /// `expires_at` must be far-future so no network refresh is attempted; the /// mock server accepts any bearer. Pair with [`oauth_env_for_pager`]. pub fn seed_fake_oauth(content: &ContentController, user: &str) { let kigi_home = content.home().join(".kigi"); std::fs::create_dir_all(&kigi_home).expect("create temp .kigi"); std::fs::write( kigi_home.join("auth.json"), format!( r#"{{ "https://auth.x.ai::b1a00492-073a-47ea-816f-4c329264a828": {{ "key": "pty-test-oauth-token", "auth_mode": "oauth", "create_time": "2026-01-01T00:00:00Z", "user_id": "{user}", "email": "{user}@test.invalid", "expires_at": "2030-01-01T00:00:00Z", "refresh_token": "pty-test-refresh-token", "oidc_issuer": "https://auth.x.ai", "oidc_client_id": "b1a00492-073a-47ea-816f-4c329264a828" }} }}"# ), ) .expect("seed fake oauth auth.json"); } /// [`ContentController::env_for_pager`] minus `XAI_API_KEY`, so the entry /// written by [`seed_fake_oauth`] is the active credential. pub fn oauth_env_for_pager(content: &ContentController) -> Vec<(String, String)> { let mut env = content.env_for_pager(); env.retain(|(k, _)| k != "XAI_API_KEY"); env } /// Drive `/new` until `model` shows on screen. Campaigns apply to **new /// sessions only** and the pager's settings prefetch is deliberately 2s-capped, /// so on a loaded runner the first session can legitimately open pre-campaign; /// each `/new` after the settings fetch lands re-resolves with the campaign. pub fn wait_for_model_via_new_sessions(h: &mut PtyHarness, model: &str, timeout: Duration) -> bool { let deadline = Instant::now() + timeout; loop { if h.contains_text(model) { return true; } if Instant::now() >= deadline { return false; } let _ = h.inject_keys(b"/new\r"); h.update(Duration::from_millis(3000)); } }