#![cfg(unix)]
use std::io::Read;
use std::path::{Path, PathBuf};
use std::process::{Command, Output, Stdio};
use std::sync::{Mutex, MutexGuard, OnceLock};
use std::time::{Duration, Instant};
use codewhale_config::{SetupState, TELEMETRY_NOTICE_VERSION};
use serde_json::{Value, json};
use tempfile::TempDir;
use wait_timeout::ChildExt;
use wiremock::matchers::{method, path as path_matcher};
use wiremock::{Mock, MockServer, ResponseTemplate};
const TELEMETRY_PATH: &str = "/v1/telemetry";
const MODEL_PATH: &str = "/v1/chat/completions";
const TEST_MODEL: &str = "telemetry-contract-model";
const SENTINEL_PROMPT: &str = "tc-prompt-sentinel-do-not-collect";
const SENTINEL_FILENAME: &str = "tc-workspace-sentinel-file.txt";
const SENTINEL_PROVIDER_TABLE: &str = "tc_custom_provider_sentinel";
const SENTINEL_MCP_SERVER: &str = "tc-mcp-server-sentinel";
const SENTINEL_API_KEY: &str = "tc-api-key-sentinel-not-a-real-key";
const SENTINEL_INJECTED: &str = "tc-injected-sentinel-/Users/victim/secret-repo";
const SENTINEL_API_KEY_ENV: &str = "TC_SENTINEL_API_KEY";
const EXEC_TIMEOUT: Duration = Duration::from_secs(90);
struct Fixture {
_process_test_guard: MutexGuard<'static, ()>,
_root: TempDir,
home: PathBuf,
codewhale_home: PathBuf,
workspace: PathBuf,
config_path: PathBuf,
endpoint: Option<String>,
}
impl Fixture {
fn new() -> Self {
let process_test_guard = telemetry_process_test_lock()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let root = TempDir::new().expect("fixture root");
let home = root.path().join("home");
let codewhale_home = root.path().join("codewhale-home");
let workspace = root.path().join("workspace");
for dir in [&home, &codewhale_home, &workspace] {
std::fs::create_dir_all(dir).expect("create fixture dir");
}
let config_path = root.path().join("config.toml");
std::fs::write(&config_path, "").expect("write config");
Self {
_process_test_guard: process_test_guard,
_root: root,
home,
codewhale_home,
workspace,
config_path,
endpoint: None,
}
}
fn with_endpoint(mut self, base_url: &str) -> Self {
self.endpoint = Some(format!("{base_url}{TELEMETRY_PATH}"));
self
}
fn write_config(&self, body: &str) {
std::fs::write(&self.config_path, body).expect("write config");
}
fn record_notice(&self, opt_in: bool) {
let mut state = SetupState::default();
state.record_telemetry_notice(TELEMETRY_NOTICE_VERSION, opt_in);
state
.save_to(&self.codewhale_home.join("setup_state.json"))
.expect("write setup state");
}
fn setup_state_path(&self) -> PathBuf {
self.codewhale_home.join("setup_state.json")
}
fn telemetry_root(&self) -> PathBuf {
self.codewhale_home.join("telemetry")
}
fn command(&self) -> Command {
let mut command = Command::new(codewhale_tui_binary());
command
.current_dir(&self.workspace)
.env_clear()
.env("PATH", std::env::var_os("PATH").expect("PATH"))
.env("HOME", &self.home)
.env("USERPROFILE", &self.home)
.env("XDG_CONFIG_HOME", self.home.join(".config"))
.env("XDG_DATA_HOME", self.home.join(".local").join("share"))
.env("XDG_CACHE_HOME", self.home.join(".cache"))
.env("CODEWHALE_HOME", &self.codewhale_home)
.env("CODEWHALE_SECRET_BACKEND", "file")
.env("CODEWHALE_MEMORY", "false")
.env(
"CODEWHALE_RELEASE_BASE_URL",
"https://example.invalid/releases",
)
.env("DEEPSEEK_TUI_VERSION", env!("CARGO_PKG_VERSION"))
.env("RUST_LOG", "warn")
.stdin(Stdio::null());
if let Some(endpoint) = &self.endpoint {
command.env("CODEWHALE_TELEMETRY_ENDPOINT", endpoint);
}
command
}
fn run_completions(&self) -> Output {
let mut command = self.command();
command.args([
"--config",
self.config_path.to_str().expect("config path"),
"completions",
"bash",
]);
let output = command.output().expect("run codewhale-tui completions");
assert!(
output.status.success(),
"completions failed\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
output
}
fn written_files(&self) -> Vec<PathBuf> {
let mut out = Vec::new();
for base in [&self.home, &self.codewhale_home, &self.workspace] {
collect_files(base, &mut out);
}
out.push(self.config_path.clone());
out
}
}
fn telemetry_process_test_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(Mutex::default)
}
fn collect_files(dir: &Path, out: &mut Vec<PathBuf>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
match entry.file_type() {
Ok(kind) if kind.is_dir() => collect_files(&path, out),
Ok(kind) if kind.is_file() => out.push(path),
_ => {}
}
}
}
fn codewhale_tui_binary() -> PathBuf {
if let Some(path) = option_env!("CARGO_BIN_EXE_codewhale-tui") {
return PathBuf::from(path);
}
if let Ok(path) = std::env::var("CARGO_BIN_EXE_codewhale-tui") {
return PathBuf::from(path);
}
let mut path = std::env::current_exe().expect("current test executable path");
path.pop();
if path.ends_with("deps") {
path.pop();
}
path.push(format!("codewhale-tui{}", std::env::consts::EXE_SUFFIX));
path
}
async fn start_recorder() -> MockServer {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path_matcher(TELEMETRY_PATH))
.respond_with(ResponseTemplate::new(200))
.mount(&server)
.await;
server
}
async fn recorded_batches(server: &MockServer) -> Vec<Value> {
let requests = server
.received_requests()
.await
.expect("the recorder must retain its request log");
requests
.iter()
.filter(|request| request.url.path() == TELEMETRY_PATH)
.map(|request| {
serde_json::from_slice::<Value>(&request.body).unwrap_or_else(|error| {
panic!(
"a telemetry batch must be JSON: {error}\nbody: {}",
String::from_utf8_lossy(&request.body)
)
})
})
.collect()
}
async fn model_request_count(server: &MockServer) -> usize {
server
.received_requests()
.await
.expect("the recorder must retain its request log")
.iter()
.filter(|request| request.url.path() == MODEL_PATH)
.count()
}
async fn assert_no_batches(server: &MockServer, why: &str) {
let batches = recorded_batches(server).await;
assert!(
batches.is_empty(),
"{why}: expected zero telemetry requests, recorded {}:\n{}",
batches.len(),
serde_json::to_string_pretty(&batches).unwrap_or_default()
);
}
fn buffered_events(fixture: &Fixture) -> Vec<Value> {
let path = fixture.telemetry_root().join("buffer.jsonl");
let body = std::fs::read_to_string(&path).unwrap_or_else(|error| {
panic!(
"read locally buffered telemetry at {}: {error}",
path.display()
)
});
body.lines()
.filter(|line| !line.trim().is_empty())
.map(|line| {
serde_json::from_str::<Value>(line).unwrap_or_else(|error| {
panic!("buffered telemetry must be JSON: {error}\nline: {line}")
})
})
.collect()
}
async fn assert_short_cli_buffered_without_network(
fixture: &Fixture,
server: &MockServer,
why: &str,
) {
assert_no_batches(server, why).await;
let events = buffered_events(fixture);
assert!(
events.iter().any(|event| event["event"] == "session_start"),
"{why}: the local buffer must carry the session it describes: {events:?}"
);
assert!(
events.iter().any(|event| event["event"] == "session_end"),
"{why}: local persistence must carry session_end: {events:?}"
);
}
#[tokio::test(flavor = "current_thread")]
async fn default_on_buffers_one_complete_session_without_network() {
let server = start_recorder().await;
let fixture = Fixture::new().with_endpoint(&server.uri());
fixture.run_completions();
assert_short_cli_buffered_without_network(&fixture, &server, "the documented default").await;
}
#[tokio::test(flavor = "current_thread")]
async fn config_file_only_opt_out_sends_zero_requests() {
let server = start_recorder().await;
let fixture = Fixture::new().with_endpoint(&server.uri());
fixture.write_config("telemetry = false\n");
fixture.record_notice(true);
let output = fixture.run_completions();
assert!(output.status.success());
assert_no_batches(&server, "`telemetry = false` in the config file").await;
assert!(
!fixture.telemetry_root().exists(),
"a fresh config-file opt-out must create no telemetry state"
);
}
#[tokio::test(flavor = "current_thread")]
async fn telemetry_disabled_by_env_sends_zero_requests() {
let server = start_recorder().await;
let fixture = Fixture::new().with_endpoint(&server.uri());
fixture.write_config("telemetry = true\n");
fixture.record_notice(true);
let mut command = fixture.command();
command
.env("CODEWHALE_TELEMETRY", "0")
.args([
"--config",
fixture.config_path.to_str().expect("config path"),
"completions",
"bash",
])
.output()
.expect("run codewhale-tui completions");
assert_no_batches(&server, "`CODEWHALE_TELEMETRY=0`").await;
assert!(
!fixture.telemetry_root().exists(),
"a fresh run-scoped opt-out must create no telemetry state"
);
}
#[tokio::test(flavor = "current_thread")]
async fn an_unparseable_telemetry_env_value_sends_zero_requests() {
let server = start_recorder().await;
let fixture = Fixture::new().with_endpoint(&server.uri());
fixture.write_config("telemetry = true\n");
fixture.record_notice(true);
fixture
.command()
.env("CODEWHALE_TELEMETRY", "maybe")
.args([
"--config",
fixture.config_path.to_str().expect("config path"),
"completions",
"bash",
])
.output()
.expect("run codewhale-tui completions");
assert_no_batches(&server, "`CODEWHALE_TELEMETRY=maybe`").await;
assert!(
!fixture.telemetry_root().exists(),
"a fresh forced-off run must create no telemetry state"
);
}
#[tokio::test(flavor = "current_thread")]
async fn telemetry_enabled_without_notice_buffers_a_complete_session() {
let server = start_recorder().await;
let fixture = Fixture::new().with_endpoint(&server.uri());
fixture.run_completions();
assert_short_cli_buffered_without_network(
&fixture,
&server,
"default-on without a notice decision",
)
.await;
}
#[tokio::test(flavor = "current_thread")]
async fn a_stale_accepted_notice_version_buffers_a_complete_session() {
let server = start_recorder().await;
let fixture = Fixture::new().with_endpoint(&server.uri());
fixture.write_config("telemetry = true\n");
let mut state = SetupState::default();
state.record_telemetry_notice("0", true);
state
.save_to(&fixture.setup_state_path())
.expect("write setup state");
fixture.run_completions();
assert_short_cli_buffered_without_network(
&fixture,
&server,
"an acceptance recorded for an older notice version",
)
.await;
}
#[tokio::test(flavor = "current_thread")]
async fn disabling_after_buffering_wipes_and_sends_nothing() {
let server = start_recorder().await;
let fixture = Fixture::new().with_endpoint(&server.uri());
let root = fixture.telemetry_root();
seed_consenting_home(&root);
fixture.write_config("telemetry = false\n");
fixture.record_notice(true);
fixture.run_completions();
assert_no_batches(&server, "an explicit opt-out with a populated buffer").await;
assert!(
root.join("disabled").exists(),
"the tombstone is written first and never removed"
);
assert_eq!(
std::fs::read(root.join("buffer.jsonl")).expect("buffer survives as an empty file"),
Vec::<u8>::new(),
"buffered events must be truncated, not sent"
);
assert!(
root.join("buffer.jsonl.lock").exists(),
"the lock file is never unlinked: replacing it would leave appenders \
and compactors holding different inodes"
);
assert!(
!root.join("install_id.json").exists(),
"the install identity must not survive an opt-out"
);
}
#[tokio::test(flavor = "current_thread")]
async fn forced_off_run_preserves_a_consenting_users_state() {
let server = start_recorder().await;
let fixture = Fixture::new().with_endpoint(&server.uri());
let root = fixture.telemetry_root();
seed_consenting_home(&root);
let before = snapshot(&root);
let mut command = fixture.command();
command
.env("CODEWHALE_TELEMETRY", "not-a-bool")
.args([
"--config",
fixture.config_path.to_str().expect("config path"),
"completions",
"bash",
])
.output()
.expect("run codewhale-tui completions");
assert_no_batches(&server, "a forced-off run").await;
assert_eq!(
snapshot(&root),
before,
"a forced-off run must leave a consenting user's telemetry state byte-identical"
);
}
#[tokio::test(flavor = "current_thread")]
async fn a_run_scoped_kill_switch_preserves_a_consenting_users_state() {
let server = start_recorder().await;
let fixture = Fixture::new().with_endpoint(&server.uri());
let root = fixture.telemetry_root();
seed_consenting_home(&root);
let before = snapshot(&root);
fixture.write_config("telemetry = true\n");
fixture.record_notice(true);
for value in ["0", "off", "false"] {
fixture
.command()
.env("CODEWHALE_TELEMETRY", value)
.args([
"--config",
fixture.config_path.to_str().expect("config path"),
"completions",
"bash",
])
.output()
.expect("run codewhale-tui completions");
assert_no_batches(&server, "a run-scoped kill switch").await;
assert!(
!root.join("disabled").exists(),
"`CODEWHALE_TELEMETRY={value}` tombstoned a machine nobody opted out"
);
assert_eq!(
snapshot(&root),
before,
"`CODEWHALE_TELEMETRY={value}` touched a consenting user's telemetry state"
);
}
fixture.write_config("telemetry = false\n");
fixture.run_completions();
assert!(
root.join("disabled").exists(),
"the config-file opt-out must still wipe and tombstone"
);
assert!(!root.join("install_id.json").exists());
}
#[tokio::test(flavor = "current_thread")]
async fn a_disabled_run_creates_no_telemetry_directory() {
let server = start_recorder().await;
let fixture = Fixture::new().with_endpoint(&server.uri());
fixture.write_config("telemetry = false\n");
fixture.record_notice(false);
fixture.run_completions();
assert_no_batches(&server, "a declined run").await;
assert!(
!fixture.telemetry_root().exists(),
"nothing may be created for a user who declined on a fresh home"
);
}
#[tokio::test(flavor = "current_thread")]
async fn skip_onboarding_writes_no_telemetry_decision() {
let server = start_recorder().await;
let fixture = Fixture::new().with_endpoint(&server.uri());
fixture.write_config("telemetry = true\n");
let mut command = fixture.command();
let output = command
.args([
"--config",
fixture.config_path.to_str().expect("config path"),
"--skip-onboarding",
"completions",
"bash",
])
.output()
.expect("run codewhale-tui completions");
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
for stream in [&stdout, &stderr] {
assert!(
!stream.contains("keep telemetry off"),
"the notice must not be rendered on a path that cannot answer it"
);
}
if let Some(state) = SetupState::load_from(&fixture.setup_state_path()) {
assert_eq!(
state.telemetry_notice_decided_for, None,
"skip-onboarding must leave the telemetry decision unset"
);
}
assert_short_cli_buffered_without_network(
&fixture,
&server,
"`--skip-onboarding` follows the default",
)
.await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn batch_contains_no_planted_sentinel() {
let server = start_recorder().await;
mount_model(&server, Duration::ZERO).await;
let fixture = Fixture::new().with_endpoint(&server.uri());
plant_sentinels(&fixture, &server.uri());
let output = run_exec(&fixture, SENTINEL_PROMPT);
assert_exec_succeeded(&output, "sentinel payload run");
assert!(
model_request_count(&server).await > 0,
"the sentinel prompt must actually have reached a model, or this test \
proves nothing\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let batches = recorded_batches(&server).await;
assert!(
!batches.is_empty(),
"this test is only meaningful against a batch that was actually sent"
);
let serialized = serde_json::to_string(&batches).expect("serialize batches");
for sentinel in [
SENTINEL_PROMPT,
SENTINEL_FILENAME,
SENTINEL_PROVIDER_TABLE,
SENTINEL_MCP_SERVER,
SENTINEL_API_KEY,
] {
assert!(
!serialized.contains(sentinel),
"sentinel `{sentinel}` reached a telemetry batch:\n{serialized}"
);
}
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
!stdout.contains(SENTINEL_API_KEY),
"the API key leaked into stdout"
);
assert!(
!stderr.contains(SENTINEL_API_KEY),
"the API key leaked into stderr"
);
for file in fixture.written_files() {
let Ok(bytes) = std::fs::read(&file) else {
continue;
};
assert!(
!String::from_utf8_lossy(&bytes).contains(SENTINEL_API_KEY),
"the API key leaked into {}",
file.display()
);
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_hostile_buffer_line_never_reaches_a_batch() {
let server = start_recorder().await;
mount_model(&server, Duration::from_secs(5)).await;
let fixture = Fixture::new().with_endpoint(&server.uri());
plant_sentinels(&fixture, &server.uri());
let mut command = exec_command(&fixture, "hello");
let mut child = command.spawn().expect("spawn codewhale-tui exec");
let stdout = read_in_background(child.stdout.take().expect("stdout pipe"));
let stderr = read_in_background(child.stderr.take().expect("stderr pipe"));
let buffer = fixture.telemetry_root().join("buffer.jsonl");
wait_until(Duration::from_secs(30), || buffer.exists());
append_lines(
&buffer,
&[
json!({"event": "panic", "site": SENTINEL_INJECTED}).to_string(),
json!({"event": "install_or_upgrade", "kind": "upgrade",
"previous_version": SENTINEL_INJECTED})
.to_string(),
json!({"event": "session_end", "duration_bucket": "lt_1m",
"exit_class": "clean", "cold_start_bucket": null,
"providers": [SENTINEL_INJECTED],
"counters": {"turns": 0, "tool_calls": 0, "fleet_dispatch": 0,
"workflow_run": 0, "subagent_spawn": 0,
"mcp_server_connected": 0, "memory_search": 0,
"approval_modal_shown": 0, "approval_auto_allowed": 0,
"command_palette_open": 0},
"errors": {"auth_preflight_failed": 0, "provider_http_4xx": 0,
"provider_http_5xx": 0, "tool_denied_by_policy": 0,
"tool_timeout": 0, "network_error": 0},
"turn_wall": {"lt_5s": 0, "5_30s": 0, "30_120s": 0, "gte_120s": 0}})
.to_string(),
],
);
let status = child
.wait_timeout(EXEC_TIMEOUT)
.expect("wait for codewhale-tui exec")
.expect("codewhale-tui exec must exit");
let output = Output {
status,
stdout: stdout.join().expect("stdout reader"),
stderr: stderr.join().expect("stderr reader"),
};
assert_exec_succeeded(&output, "hostile-buffer payload run");
let batches = recorded_batches(&server).await;
assert!(
!batches.is_empty(),
"this test is only meaningful against a batch that was actually sent"
);
let serialized = serde_json::to_string(&batches).expect("serialize batches");
assert!(
!serialized.contains(SENTINEL_INJECTED),
"a line appended to buffer.jsonl was POSTed verbatim:\n{serialized}"
);
}
fn append_lines(path: &Path, lines: &[String]) {
use std::io::Write as _;
let mut file = std::fs::OpenOptions::new()
.append(true)
.open(path)
.expect("open the telemetry buffer");
for line in lines {
writeln!(file, "{line}").expect("append to the telemetry buffer");
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn mid_session_opt_out_stops_the_shutdown_flush() {
let server = start_recorder().await;
mount_model(&server, Duration::from_secs(4)).await;
let fixture = Fixture::new().with_endpoint(&server.uri());
plant_sentinels(&fixture, &server.uri());
let mut command = exec_command(&fixture, "hello");
let mut child = command.spawn().expect("spawn codewhale-tui exec");
let stdout = read_in_background(child.stdout.take().expect("stdout pipe"));
let stderr = read_in_background(child.stderr.take().expect("stderr pipe"));
let buffer = fixture.telemetry_root().join("buffer.jsonl");
wait_until(Duration::from_secs(30), || {
std::fs::read_to_string(&buffer)
.map(|body| body.contains("\"event\":\"session_start\""))
.unwrap_or(false)
});
tokio::time::sleep(Duration::from_millis(250)).await;
assert_no_batches(&server, "before the shutdown flush").await;
fixture.write_config(&sentinel_config(&server.uri(), false));
let status = child
.wait_timeout(EXEC_TIMEOUT)
.expect("wait for codewhale-tui exec")
.expect("codewhale-tui exec must exit");
let output = Output {
status,
stdout: stdout.join().expect("stdout reader"),
stderr: stderr.join().expect("stderr reader"),
};
assert_exec_succeeded(&output, "mid-session opt-out run");
assert_no_batches(&server, "an opt-out written mid-session").await;
let root = fixture.telemetry_root();
assert!(
root.join("disabled").exists(),
"the opt-out wipe must leave a tombstone the next run also honours"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn ctrl_c_exits_while_a_second_process_holds_the_lock() {
let server = start_recorder().await;
mount_model(&server, Duration::from_secs(30)).await;
let fixture = Fixture::new().with_endpoint(&server.uri());
plant_sentinels(&fixture, &server.uri());
let mut command = exec_command(&fixture, "hello");
let mut child = command.spawn().expect("spawn codewhale-tui exec");
let root = fixture.telemetry_root();
let lock_path = root.join("buffer.jsonl.lock");
wait_until(Duration::from_secs(30), || {
root.join("buffer.jsonl").exists()
});
let _holder = LockHolder::take(&lock_path);
let pid = child.id() as libc::pid_t;
unsafe {
libc::kill(pid, libc::SIGINT);
}
let started = Instant::now();
let status = child
.wait_timeout(Duration::from_secs(10))
.expect("wait for codewhale-tui exec");
let status = status.unwrap_or_else(|| {
let _ = child.kill();
panic!(
"Ctrl-C blocked for {:?} while another process held the telemetry lock — \
the signal path must append without taking it",
started.elapsed()
)
});
assert_eq!(
status.code(),
Some(130),
"SIGINT must still exit 130 with the telemetry lock held elsewhere"
);
assert!(
started.elapsed() < Duration::from_secs(1),
"Ctrl-C took {:?} while the telemetry lock was held elsewhere",
started.elapsed()
);
}
struct LockHolder {
file: std::fs::File,
}
impl LockHolder {
fn take(path: &Path) -> Self {
let file = std::fs::OpenOptions::new()
.create(true)
.read(true)
.write(true)
.truncate(false)
.open(path)
.expect("open the telemetry lock");
let fd = std::os::unix::io::AsRawFd::as_raw_fd(&file);
let started = Instant::now();
loop {
let taken = unsafe { libc::flock(fd, libc::LOCK_EX | libc::LOCK_NB) };
if taken == 0 {
break;
}
let err = std::io::Error::last_os_error();
let retryable = err
.raw_os_error()
.is_some_and(|code| code == libc::EWOULDBLOCK || code == libc::EAGAIN);
assert!(retryable, "failed to take the telemetry lock: {err}");
assert!(
started.elapsed() < Duration::from_secs(5),
"the telemetry arming lock remained held for {:?}",
started.elapsed()
);
std::thread::sleep(Duration::from_millis(10));
}
Self { file }
}
}
impl Drop for LockHolder {
fn drop(&mut self) {
let fd = std::os::unix::io::AsRawFd::as_raw_fd(&self.file);
unsafe {
libc::flock(fd, libc::LOCK_UN);
}
}
}
fn sse_chunk(value: Value) -> String {
format!(
"data: {}\n\n",
serde_json::to_string(&value).expect("SSE JSON")
)
}
fn text_sse(text: &str) -> String {
[
sse_chunk(json!({
"id": "chatcmpl-tc",
"object": "chat.completion.chunk",
"model": TEST_MODEL,
"choices": [{"index": 0, "delta": {"content": text}, "finish_reason": null}]
})),
sse_chunk(json!({
"id": "chatcmpl-tc",
"object": "chat.completion.chunk",
"model": TEST_MODEL,
"choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}],
"usage": {"prompt_tokens": 7, "completion_tokens": 2, "total_tokens": 9}
})),
"data: [DONE]\n\n".to_string(),
]
.join("")
}
async fn mount_model(server: &MockServer, delay: Duration) {
Mock::given(method("GET"))
.and(path_matcher("/v1/models"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "application/json")
.set_body_json(json!({
"object": "list",
"data": [{"id": TEST_MODEL, "object": "model"}]
})),
)
.mount(server)
.await;
Mock::given(method("POST"))
.and(path_matcher(MODEL_PATH))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.insert_header("cache-control", "no-cache")
.set_body_string(text_sse("acknowledged"))
.set_delay(delay),
)
.mount(server)
.await;
}
fn sentinel_config(base_url: &str, telemetry: bool) -> String {
format!(
"telemetry = {telemetry}\nprovider = \"{SENTINEL_PROVIDER_TABLE}\"\n\n\
[providers.{SENTINEL_PROVIDER_TABLE}]\n\
kind = \"openai-compatible\"\n\
base_url = \"{base_url}/v1\"\n\
model = \"{TEST_MODEL}\"\n\
api_key_env = \"{SENTINEL_API_KEY_ENV}\"\n"
)
}
fn plant_sentinels(fixture: &Fixture, base_url: &str) {
fixture.write_config(&sentinel_config(base_url, true));
fixture.record_notice(true);
std::fs::write(
fixture.workspace.join(SENTINEL_FILENAME),
"sentinel workspace file\n",
)
.expect("plant workspace file");
std::fs::write(
fixture.codewhale_home.join("mcp.json"),
json!({"mcpServers": {SENTINEL_MCP_SERVER: {
"command": "/bin/true",
"args": [],
"disabled": true
}}})
.to_string(),
)
.expect("plant MCP config");
}
fn exec_command(fixture: &Fixture, prompt: &str) -> Command {
let mut command = fixture.command();
command
.env(
"CODEWHALE_MCP_CONFIG",
fixture.codewhale_home.join("mcp.json"),
)
.env(SENTINEL_API_KEY_ENV, SENTINEL_API_KEY)
.args([
"--config",
fixture.config_path.to_str().expect("config path"),
"--workspace",
fixture.workspace.to_str().expect("workspace path"),
"--no-project-config",
"--skip-onboarding",
"exec",
"--auto",
"--output-format",
"stream-json",
"--",
prompt,
])
.stdout(Stdio::piped())
.stderr(Stdio::piped());
command
}
fn run_exec(fixture: &Fixture, prompt: &str) -> Output {
let mut command = exec_command(fixture, prompt);
let mut child = command.spawn().expect("spawn codewhale-tui exec");
let stdout = read_in_background(child.stdout.take().expect("stdout pipe"));
let stderr = read_in_background(child.stderr.take().expect("stderr pipe"));
let status = match child.wait_timeout(EXEC_TIMEOUT).expect("wait for exec") {
Some(status) => status,
None => {
let _ = child.kill();
panic!("codewhale-tui exec did not exit within {EXEC_TIMEOUT:?}");
}
};
Output {
status,
stdout: stdout.join().expect("stdout reader"),
stderr: stderr.join().expect("stderr reader"),
}
}
fn assert_exec_succeeded(output: &Output, context: &str) {
assert!(
output.status.success(),
"{context} exited with {}\nstdout:\n{}\nstderr:\n{}",
output.status,
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
fn read_in_background(mut pipe: impl Read + Send + 'static) -> std::thread::JoinHandle<Vec<u8>> {
std::thread::spawn(move || {
let mut buffer = Vec::new();
let _ = pipe.read_to_end(&mut buffer);
buffer
})
}
fn wait_until(limit: Duration, mut ready: impl FnMut() -> bool) {
let started = Instant::now();
while started.elapsed() < limit {
if ready() {
return;
}
std::thread::sleep(Duration::from_millis(25));
}
panic!("condition was not reached within {limit:?}");
}
fn seed_consenting_home(root: &Path) {
std::fs::create_dir_all(root).expect("create telemetry root");
std::fs::write(
root.join("install_id.json"),
json!({
"schema_version": 1,
"install_id": "11111111-2222-3333-4444-555555555555",
"rotated_at": "2026-01-01T00:00:00Z"
})
.to_string(),
)
.expect("seed install id");
std::fs::write(
root.join("state.json"),
json!({"schema_version": 1, "last_version": "0.0.1"}).to_string(),
)
.expect("seed state");
std::fs::write(
root.join("buffer.jsonl"),
format!(
"{}\n",
json!({"event": "session_start", "source": "unknown"})
),
)
.expect("seed buffer");
std::fs::write(root.join("buffer.jsonl.lock"), b"").expect("seed lock file");
}
fn snapshot(root: &Path) -> Vec<(String, Vec<u8>)> {
let mut files = Vec::new();
collect_files(root, &mut files);
let mut out: Vec<(String, Vec<u8>)> = files
.into_iter()
.map(|path| {
let name = path
.strip_prefix(root)
.unwrap_or(&path)
.to_string_lossy()
.into_owned();
let bytes = std::fs::read(&path).unwrap_or_default();
(name, bytes)
})
.collect();
out.sort();
out
}