grok-build-upstream-mirror/crates/codegen/xai-grok-pager-pty-harness/tests/privacy_banner_e2e.rs
grokkybara[bot] 5da6962e4a Synced from monorepo
Synced from monorepo

Changes:
- Workspace server: surface preview-proxy metrics through the hub metric pump
- Shell: reclaim a session’s retained state in one entry
- Shell: reclaim a session’s resident state in one entry
- Pager: withhold key event types from Alacritty builds that double keys
- Tools: cancel a session’s subagents when it closes
- Pager: keep the whole plan in scrollback and separate reasoning from output in minimal mode
- Pager: probe terminal version over DA2 and include it with feedback
- SuperGrok Plus: identity, CLI, and analytics tier surfaces
- Shell: inherit the session process scope into subagents
- Pager: build @-file-search matcher lazily on first use
- Tools: fix description and output contradictions in tool definitions
- Workspace: degrade @-file-search instead of aborting on thread exhaustion
- Tools: reap a session’s LSP servers when it closes
- Tools: fix contradictions and defects in tool descriptions, schemas, and harness pools
- MCP: reap stdio MCP children on session close
- Shell: reuse spawn-time skill discovery for session telemetry
- Tools: stop leaking shell-wrapper positional params into sourced scripts (fixes activate_conda under persistent/static shell)
- Shell: self-heal corrupt session-search SQLite cache
- Workspace: cap workspace-server tokio workers on many-core hosts
- Shell: reap a session’s child processes when it closes
- Crash handler: capture SIGABRT so panic-aborts leave crash reports
- CLI chat proxy: team-scoped Grok Code managed-config admin routes
- MCP: add CLI enable/disable for MCP servers
- Shell: cap tokio worker threads for startup thread demand
- Workspace: harden git_commit and add git_sync_base operation
- Circuit breaker: add feature-gated gRPC retry policy

Source-Revision: 2a818575225183d8ca915f5632a09b8067b5156a
2026-07-28 22:50:19 +00:00

246 lines
9.2 KiB
Rust

//! E2E: the coding-data privacy upsell banner — shown on the welcome screen
//! for an opted-out OAuth user under the `privacy_notice_rollout` flag,
//! persisting into the agent view, and acked (never re-shown) via both
//! buttons: `[Opt out]` dismisses on the spot, stamping
//! `[privacy].privacy_banner_acked` without waiting on the server; `[Opt in]`
//! opts the user in through the shell's `PUT /privacy/coding-data-retention`
//! round trip and acks only once that succeeds.
//!
//! Drives the real pager binary through a PTY against the shared mock
//! inference server (isolated `$HOME`), with a seeded opted-out OAuth entry
//! as the active auth (`XAI_API_KEY` removed) and the rollout forced on via
//! `GROK_PRIVACY_NOTICE_ROLLOUT=1`.
//!
//! ```bash
//! cargo test -p xai-grok-pager-pty-harness --test privacy_banner_e2e \
//! -- --ignored --nocapture
//! ```
use std::path::Path;
use std::time::{Duration, Instant};
use anyhow::{Context, Result, bail};
use xai_grok_pager_pty_harness::{
ContentController, EnvOp, PtyExitPoll, PtyHarness, keys, pager_binary,
seed_fake_oauth_coding_data_opted_out,
};
const ROWS: u16 = 50;
const COLS: u16 = 120;
const BANNER_TITLE: &str = "Help improve Grok";
const OPT_OUT: &str = "[Opt out]";
const OPT_IN: &str = "[Opt in]";
const ACK: &str = "BANNERACK";
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore] // opt-in: spawns the real pager binary in a PTY (CI runs with --ignored)
async fn privacy_banner_welcome_opt_out_ack_persists() {
run_opt_out().await.expect("privacy banner opt-out e2e");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore] // opt-in: spawns the real pager binary in a PTY (CI runs with --ignored)
async fn privacy_banner_persists_into_agent_view_and_opt_in_shares() {
run_opt_in().await.expect("privacy banner opt-in e2e");
}
/// Rollout flag forced on (env override beats remote settings) and the
/// sandbox's fake `XAI_API_KEY` removed so the seeded opted-out OAuth entry
/// is the active auth — the banner's two preconditions.
fn banner_env_ops() -> [EnvOp<'static>; 2] {
[
EnvOp::set("GROK_PRIVACY_NOTICE_ROLLOUT", "1"),
EnvOp::remove("XAI_API_KEY"),
]
}
async fn run_opt_out() -> Result<()> {
let content = ContentController::start()
.await
.context("start mock server")?;
seed_fake_oauth_coding_data_opted_out(&content, "pty-privacy-user");
let project = tempfile::tempdir().context("project dir")?;
std::fs::create_dir_all(project.path().join(".git")).context("create .git")?;
let binary = pager_binary().context("resolve pager binary")?;
let mut pager = spawn_pager(&binary, &content, project.path()).context("spawn pager")?;
wait_for_banner(&mut pager)?;
assert!(
pager.contains_text(OPT_IN),
"welcome banner is missing {OPT_IN}:\n{}",
pager.screen_contents()
);
click_text(&mut pager, OPT_OUT).context("click Opt out")?;
// Dismissal is local and immediate — it must not wait on the server, and
// must not detour into settings.
pager
.wait_for_text_absent(BANNER_TITLE, Duration::from_secs(10))
.context("banner dismissed by [Opt out]")?;
assert!(
!pager.contains_text("Coding data,"),
"[Opt out] must answer the question, not open settings:\n{}",
pager.screen_contents()
);
// The config write is async — poll for it.
wait_for_ack_on_disk(&mut pager, content.home(), Duration::from_secs(10))?;
quit_via_double_ctrl_c(&mut pager)?;
drop(pager);
// Relaunch with the same sandbox: the acked banner must not re-show.
// Sync on "New worktree" — rendered only on the authenticated welcome
// menu ("Quit" also appears while auth is still pending, where the
// banner is gated off regardless of the ack).
let mut relaunched =
spawn_pager(&binary, &content, project.path()).context("relaunch pager")?;
relaunched
.wait_for_text("New worktree", Duration::from_secs(20))
.context("relaunched authenticated welcome screen")?;
relaunched.update(Duration::from_secs(2));
assert!(
!relaunched.contains_text(BANNER_TITLE),
"acked banner re-showed after relaunch:\n{}",
relaunched.screen_contents()
);
Ok(())
}
async fn run_opt_in() -> Result<()> {
let content = ContentController::start()
.await
.context("start mock server")?;
content.set_response(format!("{ACK} done."));
seed_fake_oauth_coding_data_opted_out(&content, "pty-privacy-user");
let project = tempfile::tempdir().context("project dir")?;
std::fs::create_dir_all(project.path().join(".git")).context("create .git")?;
let binary = pager_binary().context("resolve pager binary")?;
let mut pager = spawn_pager(&binary, &content, project.path()).context("spawn pager")?;
wait_for_banner(&mut pager)?;
pager.inject_keys(b"hello").context("type prompt")?;
pager.inject_keys(keys::ENTER).context("submit prompt")?;
pager
.wait_for_text(ACK, Duration::from_secs(30))
.context("turn response rendered")?;
pager.update(Duration::from_millis(1000));
assert!(
pager.contains_text(BANNER_TITLE),
"banner did not persist into the agent view:\n{}",
pager.screen_contents()
);
click_text(&mut pager, OPT_IN).context("click Opt in")?;
// Ack only lands after the shell's PUT round trip confirms 2xx.
pager
.wait_for_text_absent(BANNER_TITLE, Duration::from_secs(20))
.context("banner disappeared after [Opt in]")?;
wait_for_ack_on_disk(&mut pager, content.home(), Duration::from_secs(10))?;
let put_bodies: Vec<_> = content
.requests()
.iter()
.filter(|e| e.method == "PUT" && e.path == "/v1/privacy/coding-data-retention")
.filter_map(|e| e.body.clone())
.collect();
assert!(
put_bodies
.iter()
.any(|b| b["codingDataRetentionOptOut"] == serde_json::json!(false)),
"mock server did not see the opt-in PUT; got: {put_bodies:?}"
);
Ok(())
}
fn spawn_pager(binary: &Path, content: &ContentController, project: &Path) -> Result<PtyHarness> {
PtyHarness::spawn_with_content_env_ops_in_dir(
binary,
ROWS,
COLS,
content,
&[],
&banner_env_ops(),
Some(project),
)
}
/// Wait for the welcome menu first (auth resolved) so a missing banner is a
/// real failure rather than an early frame, then for the banner itself.
fn wait_for_banner(pager: &mut PtyHarness) -> Result<()> {
pager
.wait_for_text("Quit", Duration::from_secs(20))
.context("welcome screen")?;
pager
.wait_for_text(BANNER_TITLE, Duration::from_secs(20))
.context("privacy banner on screen")
}
/// Click `needle` by injecting an SGR (DECSET 1006) press + release at its
/// first character. The wire encoding is 1-based `col;row`
/// (`screen_contents` line 0 = row 1); the banner region is ASCII-only, so
/// the byte offset within the line is the column.
fn click_text(pager: &mut PtyHarness, needle: &str) -> Result<()> {
let screen = pager.screen_contents();
let (row0, col0) = screen
.lines()
.enumerate()
.find_map(|(row, line)| line.find(needle).map(|col| (row, col)))
.with_context(|| format!("{needle:?} not on screen:\n{screen}"))?;
let (row, col) = (row0 + 1, col0 + 1);
pager
.inject_keys(format!("\x1b[<0;{col};{row}M\x1b[<0;{col};{row}m").as_bytes())
.context("inject SGR click")?;
pager.update(Duration::from_millis(250));
Ok(())
}
/// Poll `<home>/.grok/config.toml` for the async `privacy_banner_acked`
/// write, pumping PTY output between polls so the pager never blocks on a
/// full output buffer.
fn wait_for_ack_on_disk(pager: &mut PtyHarness, home: &Path, timeout: Duration) -> Result<()> {
let path = home.join(".grok").join("config.toml");
let deadline = Instant::now() + timeout;
loop {
let body = std::fs::read_to_string(&path).unwrap_or_default();
if body.contains("privacy_banner_acked") {
return Ok(());
}
if Instant::now() >= deadline {
bail!(
"timed out after {timeout:?} waiting for privacy_banner_acked in {}\n\
config contents:\n{body}\nscreen:\n{}",
path.display(),
pager.screen_contents()
);
}
pager.update(Duration::from_millis(100));
}
}
/// First Ctrl+C arms the quit confirmation on the empty prompt, the second
/// confirms; retry the pair in case an overlay swallowed the first one.
fn quit_via_double_ctrl_c(pager: &mut PtyHarness) -> Result<()> {
for _ in 0..3 {
pager.inject_keys(keys::CTRL_C).context("ctrl-c arm")?;
pager.update(Duration::from_millis(250));
pager.inject_keys(keys::CTRL_C).context("ctrl-c confirm")?;
pager.update(Duration::from_millis(250));
match pager.wait_exit_code(Duration::from_secs(5))? {
PtyExitPoll::Exited(code) => {
assert_eq!(code, 0, "graceful quit should exit 0, got {code}");
return Ok(());
}
_ => continue,
}
}
bail!(
"pager did not exit after repeated double Ctrl+C\nscreen:\n{}",
pager.screen_contents()
)
}