From de877fca298b76fedf39d6d317745c72a98e4040 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Halber?= Date: Mon, 20 Apr 2026 21:51:24 +0000 Subject: [PATCH 1/6] chore: e2e test for bt setup without any arguments --- Cargo.lock | 157 +++++++++++++ Cargo.toml | 1 + tests/setup_e2e.rs | 561 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 719 insertions(+) create mode 100644 tests/setup_e2e.rs diff --git a/Cargo.lock b/Cargo.lock index 1a42ca1..b87a205 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -523,6 +523,7 @@ dependencies = [ "oauth2", "open", "pathdiff", + "portable-pty", "predicates", "ratatui", "regex", @@ -1053,6 +1054,12 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + [[package]] name = "dyn-clone" version = "1.0.20" @@ -1102,6 +1109,17 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -1690,6 +1708,15 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "ioctl-rs" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7970510895cee30b3e9128319f2cefd4bde883a39f38baa279567ba3a7eb97d" +dependencies = [ + "libc", +] + [[package]] name = "ipnet" version = "2.11.0" @@ -1781,6 +1808,12 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4345964bb142484797b161f473a503a434de77149dd8c7427788c6e13379388" +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "leb128fmt" version = "0.1.0" @@ -1910,6 +1943,15 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "memoffset" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aa361d4faea93603064a027415f07bd8e1d5c88c9fbf68bf56a285428fd79ce" +dependencies = [ + "autocfg", +] + [[package]] name = "mime" version = "0.3.17" @@ -1954,6 +1996,20 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" +[[package]] +name = "nix" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f346ff70e7dbfd675fe90590b92d59ef2de15a8779ae305ebcbfd3f0caf59be4" +dependencies = [ + "autocfg", + "bitflags 1.3.2", + "cfg-if", + "libc", + "memoffset", + "pin-utils", +] + [[package]] name = "normalize-line-endings" version = "0.3.0" @@ -2147,6 +2203,27 @@ version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" +[[package]] +name = "portable-pty" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "806ee80c2a03dbe1a9fb9534f8d19e4c0546b790cde8fd1fea9d6390644cb0be" +dependencies = [ + "anyhow", + "bitflags 1.3.2", + "downcast-rs", + "filedescriptor", + "lazy_static", + "libc", + "log", + "nix", + "serial", + "shared_library", + "shell-words", + "winapi", + "winreg 0.10.1", +] + [[package]] name = "potential_utf" version = "0.1.4" @@ -2875,6 +2952,48 @@ dependencies = [ "unsafe-libyaml", ] +[[package]] +name = "serial" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1237a96570fc377c13baa1b88c7589ab66edced652e43ffb17088f003db3e86" +dependencies = [ + "serial-core", + "serial-unix", + "serial-windows", +] + +[[package]] +name = "serial-core" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f46209b345401737ae2125fe5b19a77acce90cd53e1658cda928e4fe9a64581" +dependencies = [ + "libc", +] + +[[package]] +name = "serial-unix" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f03fbca4c9d866e24a459cbca71283f545a37f8e3e002ad8c70593871453cab7" +dependencies = [ + "ioctl-rs", + "libc", + "serial-core", + "termios", +] + +[[package]] +name = "serial-windows" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15c6d3b776267a75d31bbdfd5d36c0ca051251caafc285827052bc53bcdc8162" +dependencies = [ + "libc", + "serial-core", +] + [[package]] name = "sha1" version = "0.10.6" @@ -2897,6 +3016,16 @@ dependencies = [ "digest", ] +[[package]] +name = "shared_library" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11" +dependencies = [ + "lazy_static", + "libc", +] + [[package]] name = "shell-words" version = "1.1.1" @@ -3086,6 +3215,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "termios" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5d9cf598a6d7ce700a4e6a9199da127e6819a61e64b68609683cc9a01b5683a" +dependencies = [ + "libc", +] + [[package]] name = "termtree" version = "0.5.1" @@ -4029,6 +4167,25 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" +dependencies = [ + "winapi", +] + +[[package]] +name = "winreg" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + [[package]] name = "wit-bindgen" version = "0.51.0" diff --git a/Cargo.toml b/Cargo.toml index e239e78..f140d85 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -61,6 +61,7 @@ members = ["."] [dev-dependencies] assert_cmd = "2.2.0" predicates = "3.1.4" +portable-pty = "0.8" [target.'cfg(windows)'.dependencies] windows-sys = { version = "0.59", features = ["Win32_Storage_FileSystem"] } diff --git a/tests/setup_e2e.rs b/tests/setup_e2e.rs new file mode 100644 index 0000000..04fcc85 --- /dev/null +++ b/tests/setup_e2e.rs @@ -0,0 +1,561 @@ +#![cfg(unix)] + +use assert_cmd::cargo::cargo_bin; +use portable_pty::{native_pty_system, CommandBuilder, PtySize}; +use reqwest::Url; +use std::fs; +use std::io::{Read, Write}; +use std::net::{SocketAddr, TcpListener, TcpStream}; +use std::os::unix::fs::PermissionsExt; +use std::path::Path; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::thread; +use std::time::{Duration, Instant}; + +const TIMEOUT: Duration = Duration::from_secs(20); +const WORKFLOWS: &[&str] = &["instrument", "observe", "annotate", "evaluate", "deploy"]; + +#[test] +fn bare_setup_supports_interactive_oauth_org_project_and_agent_selection() { + let repo = make_git_repo(); + let home = tempfile::tempdir().expect("home tempdir"); + let config_home = tempfile::tempdir().expect("config tempdir"); + let bin_dir = tempfile::tempdir().expect("bin tempdir"); + let browser_log = home.path().join("opened-url.txt"); + let codex_log = repo.path().join("codex.log"); + let path_env = format!( + "{}:{}", + bin_dir.path().display(), + std::env::var("PATH").unwrap_or_default() + ); + + write_executable( + &bin_dir.path().join("codex"), + &format!( + "#!/bin/sh\nprintf '%s\\n' \"$*\" >> '{}'\nexit 0\n", + codex_log.display() + ), + ); + write_executable(&bin_dir.path().join("claude"), "#!/bin/sh\nexit 0\n"); + write_executable( + &bin_dir.path().join("fake-browser"), + &format!( + "#!/bin/sh\nprintf '%s\\n' \"$1\" > '{}'\nexit 0\n", + browser_log.display() + ), + ); + write_executable( + &bin_dir.path().join("xdg-open"), + &format!( + "#!/bin/sh\nprintf '%s\\n' \"$1\" > '{}'\nexit 0\n", + browser_log.display() + ), + ); + write_executable(&bin_dir.path().join("secret-tool"), "#!/bin/sh\nexit 1\n"); + + seed_docs_cache(&config_home.path().join("bt").join("skills").join("docs")); + seed_docs_cache(&repo.path().join(".bt").join("skills").join("docs")); + + let server = FakeBtServer::start(); + let bt_bin = cargo_bin("bt"); + + let mut pty = PtyProcess::spawn( + bt_bin.as_path(), + repo.path(), + vec![ + ("HOME".to_string(), home.path().display().to_string()), + ( + "XDG_CONFIG_HOME".to_string(), + config_home.path().display().to_string(), + ), + ("PATH".to_string(), path_env), + ( + "BROWSER".to_string(), + bin_dir.path().join("fake-browser").display().to_string(), + ), + ("SSH_CONNECTION".to_string(), "test".to_string()), + ], + &[ + "setup", + "--api-url", + &server.base_url, + "--app-url", + &server.base_url, + ], + ); + + let oauth_stage = + pty.wait_for_any(&["Callback URL/query/JSON", "Select organization"], TIMEOUT); + if oauth_stage == "Callback URL/query/JSON" { + let authorize_url = wait_for_authorize_url(&pty, &browser_log, TIMEOUT); + let state = query_value( + &Url::parse(authorize_url.trim()).expect("authorize url"), + "state", + ); + pty.send(&format!("?code=test-oauth-code&state={state}\r")); + pty.wait_for("Select organization", TIMEOUT); + } + + pty.send("\u{1b}[B"); + pty.send("\u{1b}[A"); + pty.send("\u{1b}[B"); + pty.send("\r"); + + pty.wait_for("Select project", TIMEOUT); + pty.send("targetx"); + pty.send("\u{7f}"); + pty.send("\r"); + + pty.wait_for("Select coding agent", TIMEOUT); + pty.send("\r"); + + let status = pty.wait(TIMEOUT); + assert!( + status.success(), + "bt setup exited with {status:?}\n{}", + pty.snapshot() + ); + + let config_path = repo.path().join(".bt").join("config.json"); + let config_text = fs::read_to_string(&config_path).expect("read config"); + assert!( + config_text.contains("\"org\": \"Target Org\""), + "{config_text}" + ); + assert!( + config_text.contains("\"project\": \"Target Project\""), + "{config_text}" + ); + assert!( + config_text.contains("\"project_id\": \"project-target\""), + "{config_text}" + ); + + let codex_args = fs::read_to_string(&codex_log).expect("read codex log"); + assert!(!codex_args.trim().is_empty(), "codex was not invoked"); + + let requests = server.requests(); + assert!( + requests + .iter() + .any(|request| request == "POST /oauth/token"), + "missing oauth token request: {requests:?}" + ); + assert!( + requests + .iter() + .filter(|request| request.starts_with("POST /api/apikey/login")) + .count() + >= 1, + "missing api login request: {requests:?}" + ); + assert!( + requests + .iter() + .any(|request| request.contains("GET /v1/project?org_name=Target%20Org")), + "missing target org project request: {requests:?}" + ); + + assert!(home + .path() + .join(".agents/skills/braintrust/SKILL.md") + .exists()); + assert!(repo + .path() + .join(".agents/skills/braintrust/SKILL.md") + .exists()); +} + +fn make_git_repo() -> tempfile::TempDir { + let dir = tempfile::tempdir().expect("tempdir"); + fs::write(dir.path().join(".git"), "gitdir: /tmp/fake").expect("write .git"); + dir +} + +fn write_executable(path: &Path, content: &str) { + fs::write(path, content).expect("write executable"); + let mut perms = fs::metadata(path).expect("metadata").permissions(); + perms.set_mode(0o755); + fs::set_permissions(path, perms).expect("chmod"); +} + +fn seed_docs_cache(output_dir: &Path) { + fs::create_dir_all(output_dir.join("reference")).expect("create docs dir"); + fs::write(output_dir.join("README.md"), "# Docs\n").expect("write docs readme"); + fs::write(output_dir.join("reference").join("sql.md"), "# SQL\n").expect("write sql doc"); + for workflow in WORKFLOWS { + let workflow_dir = output_dir.join(workflow); + fs::create_dir_all(&workflow_dir).expect("create workflow dir"); + fs::write(workflow_dir.join("_index.md"), format!("# {workflow}\n")) + .expect("write workflow doc"); + } +} + +struct PtyProcess { + child: Box, + writer: Box, + output: Arc>>, + reader_thread: Option>, +} + +impl PtyProcess { + fn spawn(program: &Path, cwd: &Path, envs: Vec<(String, String)>, args: &[&str]) -> Self { + let pty_system = native_pty_system(); + let pair = pty_system + .openpty(PtySize { + rows: 40, + cols: 120, + pixel_width: 0, + pixel_height: 0, + }) + .expect("open pty"); + let mut cmd = CommandBuilder::new(program); + for arg in args { + cmd.arg(arg); + } + cmd.cwd(cwd); + for (key, value) in envs { + cmd.env(key, value); + } + for key in [ + "BRAINTRUST_API_KEY", + "BRAINTRUST_PROFILE", + "BRAINTRUST_ORG_NAME", + "BRAINTRUST_DEFAULT_PROJECT", + ] { + cmd.env_remove(key); + } + cmd.env("TERM", "xterm-256color"); + cmd.env("NO_COLOR", "1"); + let child = pair.slave.spawn_command(cmd).expect("spawn bt"); + drop(pair.slave); + + let output = Arc::new(Mutex::new(Vec::new())); + let mut reader = pair.master.try_clone_reader().expect("clone reader"); + let reader_output = Arc::clone(&output); + let reader_thread = thread::spawn(move || { + let mut buf = [0u8; 4096]; + loop { + match reader.read(&mut buf) { + Ok(0) => break, + Ok(n) => reader_output + .lock() + .expect("lock output") + .extend_from_slice(&buf[..n]), + Err(_) => break, + } + } + }); + let writer = pair.master.take_writer().expect("take writer"); + + Self { + child, + writer, + output, + reader_thread: Some(reader_thread), + } + } + + fn send(&mut self, input: &str) { + self.writer + .write_all(input.as_bytes()) + .expect("write to pty"); + self.writer.flush().expect("flush pty"); + } + + fn wait_for(&self, needle: &str, timeout: Duration) { + let deadline = Instant::now() + timeout; + loop { + if self.snapshot().contains(needle) { + return; + } + assert!( + Instant::now() < deadline, + "timed out waiting for '{needle}'\n{}", + self.snapshot() + ); + thread::sleep(Duration::from_millis(50)); + } + } + + fn wait_for_any<'a>(&self, needles: &[&'a str], timeout: Duration) -> &'a str { + let deadline = Instant::now() + timeout; + loop { + let snapshot = self.snapshot(); + if let Some(found) = needles + .iter() + .copied() + .find(|needle| snapshot.contains(needle)) + { + return found; + } + assert!( + Instant::now() < deadline, + "timed out waiting for any of {:?}\n{}", + needles, + snapshot + ); + thread::sleep(Duration::from_millis(50)); + } + } + + fn wait(&mut self, timeout: Duration) -> portable_pty::ExitStatus { + let deadline = Instant::now() + timeout; + loop { + if let Some(status) = self.child.try_wait().expect("poll child") { + if let Some(thread) = self.reader_thread.take() { + thread.join().expect("join reader"); + } + return status; + } + assert!( + Instant::now() < deadline, + "timed out waiting for process exit\n{}", + self.snapshot() + ); + thread::sleep(Duration::from_millis(50)); + } + } + + fn snapshot(&self) -> String { + let bytes = self.output.lock().expect("lock output").clone(); + String::from_utf8_lossy(&strip_ansi_escapes::strip(&bytes)).into_owned() + } +} + +fn wait_for_authorize_url(pty: &PtyProcess, browser_log: &Path, timeout: Duration) -> String { + let deadline = Instant::now() + timeout; + loop { + if let Ok(contents) = fs::read_to_string(browser_log) { + if let Some(url) = extract_authorize_url(&contents) { + return url; + } + } + if let Some(url) = extract_authorize_url(&pty.snapshot()) { + return url; + } + assert!( + Instant::now() < deadline, + "timed out waiting for authorize url\n{}", + pty.snapshot() + ); + thread::sleep(Duration::from_millis(50)); + } +} + +fn extract_authorize_url(text: &str) -> Option { + for token in text.split_whitespace() { + if token.starts_with("http://") && token.contains("/oauth/authorize") { + return Some(token.trim().to_string()); + } + } + None +} + +struct FakeBtServer { + base_url: String, + requests: Arc>>, + stop: Arc, + thread: Option>, +} + +impl FakeBtServer { + fn start() -> Self { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind fake server"); + listener + .set_nonblocking(true) + .expect("set listener nonblocking"); + let addr = listener.local_addr().expect("server addr"); + let requests = Arc::new(Mutex::new(Vec::new())); + let stop = Arc::new(AtomicBool::new(false)); + let stop_flag = Arc::clone(&stop); + let requests_log = Arc::clone(&requests); + let base_url = format!("http://{}", addr); + + let thread = thread::spawn(move || { + while !stop_flag.load(Ordering::Relaxed) { + match listener.accept() { + Ok((stream, _)) => handle_fake_bt_request(stream, addr, &requests_log), + Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => { + thread::sleep(Duration::from_millis(20)); + } + Err(err) => panic!("fake server accept failed: {err}"), + } + } + }); + + Self { + base_url, + requests, + stop, + thread: Some(thread), + } + } + + fn requests(&self) -> Vec { + self.requests.lock().expect("lock requests").clone() + } +} + +impl Drop for FakeBtServer { + fn drop(&mut self) { + self.stop.store(true, Ordering::Relaxed); + let _ = TcpStream::connect(self.base_url.trim_start_matches("http://")); + if let Some(thread) = self.thread.take() { + thread.join().expect("join fake server"); + } + } +} + +fn handle_fake_bt_request( + mut stream: TcpStream, + addr: SocketAddr, + requests: &Arc>>, +) { + let request = read_http_request(&mut stream); + requests + .lock() + .expect("lock requests") + .push(format!("{} {}", request.method, request.target)); + let url = Url::parse(&format!("http://{}{}", addr, request.target)).expect("parse url"); + let path = url.path(); + + match (request.method.as_str(), path) { + ("GET", "/oauth/authorize") => { + let redirect_uri = query_value(&url, "redirect_uri"); + let state = query_value(&url, "state"); + let location = format!("{redirect_uri}?code=test-oauth-code&state={state}"); + write_response( + &mut stream, + 302, + &[("Location", &location), ("Content-Length", "0")], + b"", + ); + } + ("POST", "/oauth/token") => { + let body = r#"{"access_token":"not-a-jwt","token_type":"Bearer","refresh_token":"refresh-token","expires_in":3600}"#; + write_json(&mut stream, 200, body); + } + ("POST", "/api/apikey/login") => { + let api_url = format!("http://{}", addr); + let body = format!( + r#"{{"org_info":[{{"id":"org-alpha","name":"Alpha Org","api_url":"{api_url}"}},{{"id":"org-target","name":"Target Org","api_url":"{api_url}"}}]}}"# + ); + write_json(&mut stream, 200, &body); + } + ("GET", "/v1/project") => { + let org_name = query_value(&url, "org_name"); + let body = match org_name.as_str() { + "Alpha Org" => { + r#"{"objects":[{"id":"project-alpha","name":"Alpha Project","org_id":"org-alpha"}]}"# + } + "Target Org" => { + r#"{"objects":[{"id":"project-alpha","name":"Alpha Project","org_id":"org-target"},{"id":"project-target","name":"Target Project","org_id":"org-target"}]}"# + } + other => panic!("unexpected org_name {other}"), + }; + write_json(&mut stream, 200, body); + } + ("GET", "/v1/api_key") => { + write_json(&mut stream, 200, r#"{"objects":[]}"#); + } + ("POST", "/v1/api_key") => { + write_json(&mut stream, 200, r#"{"key":"generated-api-key"}"#); + } + _ => panic!( + "unexpected fake server request: {} {}", + request.method, request.target + ), + } +} + +struct HttpRequest { + method: String, + target: String, +} + +fn read_http_request(stream: &mut TcpStream) -> HttpRequest { + stream + .set_read_timeout(Some(Duration::from_secs(5))) + .expect("set read timeout"); + + let mut buffer = Vec::new(); + let mut temp = [0u8; 1024]; + let header_end = loop { + let read = stream.read(&mut temp).expect("read request"); + assert!(read > 0, "connection closed before headers"); + buffer.extend_from_slice(&temp[..read]); + if let Some(pos) = find_header_end(&buffer) { + break pos; + } + }; + + let headers = String::from_utf8_lossy(&buffer[..header_end]); + let mut lines = headers.lines(); + let request_line = lines.next().expect("request line"); + let mut parts = request_line.split_whitespace(); + let method = parts.next().expect("method").to_string(); + let target = parts.next().expect("target").to_string(); + + let content_length = lines + .find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse::().expect("content-length")) + }) + .unwrap_or(0); + let current_body_len = buffer.len() - (header_end + 4); + let mut remaining = content_length.saturating_sub(current_body_len); + while remaining > 0 { + let read = stream.read(&mut temp).expect("read body"); + assert!(read > 0, "connection closed before body"); + buffer.extend_from_slice(&temp[..read]); + remaining = remaining.saturating_sub(read); + } + + HttpRequest { method, target } +} + +fn find_header_end(buffer: &[u8]) -> Option { + buffer.windows(4).position(|window| window == b"\r\n\r\n") +} + +fn query_value(url: &Url, key: &str) -> String { + url.query_pairs() + .find_map(|(name, value)| (name == key).then(|| value.into_owned())) + .unwrap_or_else(|| panic!("missing query parameter {key} in {url}")) +} + +fn write_json(stream: &mut TcpStream, status: u16, body: &str) { + write_response( + stream, + status, + &[ + ("Content-Type", "application/json"), + ("Content-Length", &body.len().to_string()), + ], + body.as_bytes(), + ); +} + +fn write_response(stream: &mut TcpStream, status: u16, headers: &[(&str, &str)], body: &[u8]) { + let reason = match status { + 200 => "OK", + 302 => "Found", + _ => panic!("unexpected status {status}"), + }; + let mut response = format!("HTTP/1.1 {status} {reason}\r\nConnection: close\r\n"); + for (name, value) in headers { + response.push_str(name); + response.push_str(": "); + response.push_str(value); + response.push_str("\r\n"); + } + response.push_str("\r\n"); + + stream + .write_all(response.as_bytes()) + .expect("write response headers"); + stream.write_all(body).expect("write response body"); +} From 0cd9e8b6ff76de67a2f53f9f1008ea82ae744f2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Halber?= Date: Tue, 21 Apr 2026 00:20:51 +0000 Subject: [PATCH 2/6] chore: add more env variables to clean before the test --- tests/setup_e2e.rs | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/tests/setup_e2e.rs b/tests/setup_e2e.rs index 04fcc85..6ea64ee 100644 --- a/tests/setup_e2e.rs +++ b/tests/setup_e2e.rs @@ -15,6 +15,19 @@ use std::time::{Duration, Instant}; const TIMEOUT: Duration = Duration::from_secs(20); const WORKFLOWS: &[&str] = &["instrument", "observe", "annotate", "evaluate", "deploy"]; +const BRAINTRUST_CLI_ENVS: &[&str] = &[ + "BRAINTRUST_VERBOSE", + "BRAINTRUST_QUIET", + "BRAINTRUST_NO_COLOR", + "BRAINTRUST_NO_INPUT", + "BRAINTRUST_PROFILE", + "BRAINTRUST_ORG_NAME", + "BRAINTRUST_DEFAULT_PROJECT", + "BRAINTRUST_API_KEY", + "BRAINTRUST_API_URL", + "BRAINTRUST_APP_URL", + "BRAINTRUST_ENV_FILE", +]; #[test] fn bare_setup_supports_interactive_oauth_org_project_and_agent_selection() { @@ -218,12 +231,7 @@ impl PtyProcess { for (key, value) in envs { cmd.env(key, value); } - for key in [ - "BRAINTRUST_API_KEY", - "BRAINTRUST_PROFILE", - "BRAINTRUST_ORG_NAME", - "BRAINTRUST_DEFAULT_PROJECT", - ] { + for key in BRAINTRUST_CLI_ENVS { cmd.env_remove(key); } cmd.env("TERM", "xterm-256color"); From 43aa9eb1a4570371f93f0500a6bf08f5fc439919 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Halber?= Date: Thu, 30 Apr 2026 17:55:32 +0000 Subject: [PATCH 3/6] chore: test now fail if fake browser helper doesn't trigger --- tests/setup_e2e.rs | 165 ++++++++++++++++++++++++++++++++++----------- 1 file changed, 127 insertions(+), 38 deletions(-) diff --git a/tests/setup_e2e.rs b/tests/setup_e2e.rs index 6ea64ee..e93054f 100644 --- a/tests/setup_e2e.rs +++ b/tests/setup_e2e.rs @@ -7,14 +7,13 @@ use std::fs; use std::io::{Read, Write}; use std::net::{SocketAddr, TcpListener, TcpStream}; use std::os::unix::fs::PermissionsExt; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; use std::thread; use std::time::{Duration, Instant}; const TIMEOUT: Duration = Duration::from_secs(20); -const WORKFLOWS: &[&str] = &["instrument", "observe", "annotate", "evaluate", "deploy"]; const BRAINTRUST_CLI_ENVS: &[&str] = &[ "BRAINTRUST_VERBOSE", "BRAINTRUST_QUIET", @@ -29,13 +28,74 @@ const BRAINTRUST_CLI_ENVS: &[&str] = &[ "BRAINTRUST_ENV_FILE", ]; +struct SetupRunOutcome { + repo: tempfile::TempDir, + home: tempfile::TempDir, + _config_home: tempfile::TempDir, + claude_log: PathBuf, + codex_log: PathBuf, + requests: Vec, +} + +#[test] +fn setup_supports_oauth_api_key_creation_and_explicit_claude_selection() { + let outcome = run_setup_flow("claude"); + assert_common_setup_outcome(&outcome); + + let claude_args = fs::read_to_string(&outcome.claude_log).expect("read claude log"); + assert!( + claude_args.contains("mcp add -s user --transport http braintrust"), + "{claude_args}" + ); + assert!( + claude_args.contains("Authorization: Bearer generated-api-key"), + "{claude_args}" + ); + assert!( + claude_args.contains("-p --permission-mode acceptEdits"), + "{claude_args}" + ); + assert!( + claude_args.contains("ENV=generated-api-key"), + "{claude_args}" + ); + assert!( + !outcome.codex_log.exists(), + "codex should not be invoked when Claude is selected" + ); + assert!(outcome.home.path().join(".claude/skills").exists()); +} + #[test] -fn bare_setup_supports_interactive_oauth_org_project_and_agent_selection() { +fn setup_supports_oauth_api_key_creation_and_explicit_codex_selection() { + let outcome = run_setup_flow("codex"); + assert_common_setup_outcome(&outcome); + + let codex_args = fs::read_to_string(&outcome.codex_log).expect("read codex log"); + assert!(codex_args.contains("mcp remove braintrust"), "{codex_args}"); + assert!( + codex_args.contains("mcp add braintrust --url"), + "{codex_args}" + ); + assert!( + codex_args.contains("--bearer-token-env-var BRAINTRUST_API_KEY"), + "{codex_args}" + ); + assert!(codex_args.contains("exec -"), "{codex_args}"); + assert!(codex_args.contains("ENV=generated-api-key"), "{codex_args}"); + assert!( + !outcome.claude_log.exists(), + "claude should not be invoked when Codex is selected" + ); +} + +fn run_setup_flow(selected_agent: &str) -> SetupRunOutcome { let repo = make_git_repo(); let home = tempfile::tempdir().expect("home tempdir"); let config_home = tempfile::tempdir().expect("config tempdir"); let bin_dir = tempfile::tempdir().expect("bin tempdir"); let browser_log = home.path().join("opened-url.txt"); + let claude_log = repo.path().join("claude.log"); let codex_log = repo.path().join("codex.log"); let path_env = format!( "{}:{}", @@ -43,14 +103,22 @@ fn bare_setup_supports_interactive_oauth_org_project_and_agent_selection() { std::env::var("PATH").unwrap_or_default() ); + write_executable( + &bin_dir.path().join("claude"), + &format!( + "#!/bin/sh\nprintf '%s\\n' \"$*\" >> '{}'\nprintf 'ENV=%s\\n' \"$BRAINTRUST_API_KEY\" >> '{}'\nexit 0\n", + claude_log.display(), + claude_log.display() + ), + ); write_executable( &bin_dir.path().join("codex"), &format!( - "#!/bin/sh\nprintf '%s\\n' \"$*\" >> '{}'\nexit 0\n", + "#!/bin/sh\nprintf '%s\\n' \"$*\" >> '{}'\nprintf 'ENV=%s\\n' \"$BRAINTRUST_API_KEY\" >> '{}'\nexit 0\n", + codex_log.display(), codex_log.display() ), ); - write_executable(&bin_dir.path().join("claude"), "#!/bin/sh\nexit 0\n"); write_executable( &bin_dir.path().join("fake-browser"), &format!( @@ -67,9 +135,6 @@ fn bare_setup_supports_interactive_oauth_org_project_and_agent_selection() { ); write_executable(&bin_dir.path().join("secret-tool"), "#!/bin/sh\nexit 1\n"); - seed_docs_cache(&config_home.path().join("bt").join("skills").join("docs")); - seed_docs_cache(&repo.path().join(".bt").join("skills").join("docs")); - let server = FakeBtServer::start(); let bt_bin = cargo_bin("bt"); @@ -95,6 +160,9 @@ fn bare_setup_supports_interactive_oauth_org_project_and_agent_selection() { &server.base_url, "--app-url", &server.base_url, + "--mcp", + "--background", + "--no-workflow", ], ); @@ -110,9 +178,7 @@ fn bare_setup_supports_interactive_oauth_org_project_and_agent_selection() { pty.wait_for("Select organization", TIMEOUT); } - pty.send("\u{1b}[B"); - pty.send("\u{1b}[A"); - pty.send("\u{1b}[B"); + pty.send("Target"); pty.send("\r"); pty.wait_for("Select project", TIMEOUT); @@ -121,6 +187,7 @@ fn bare_setup_supports_interactive_oauth_org_project_and_agent_selection() { pty.send("\r"); pty.wait_for("Select coding agent", TIMEOUT); + pty.send(selected_agent); pty.send("\r"); let status = pty.wait(TIMEOUT); @@ -145,36 +212,72 @@ fn bare_setup_supports_interactive_oauth_org_project_and_agent_selection() { "{config_text}" ); - let codex_args = fs::read_to_string(&codex_log).expect("read codex log"); - assert!(!codex_args.trim().is_empty(), "codex was not invoked"); + SetupRunOutcome { + repo, + home, + _config_home: config_home, + claude_log, + codex_log, + requests: server.requests(), + } +} - let requests = server.requests(); +fn assert_common_setup_outcome(outcome: &SetupRunOutcome) { assert!( - requests + outcome + .requests .iter() .any(|request| request == "POST /oauth/token"), - "missing oauth token request: {requests:?}" + "missing oauth token request: {:?}", + outcome.requests ); assert!( - requests + outcome + .requests .iter() .filter(|request| request.starts_with("POST /api/apikey/login")) .count() >= 1, - "missing api login request: {requests:?}" + "missing api login request: {:?}", + outcome.requests + ); + assert!( + outcome + .requests + .iter() + .any(|request| request == "GET /v1/api_key"), + "missing api key list request: {:?}", + outcome.requests + ); + assert!( + outcome + .requests + .iter() + .any(|request| request == "POST /v1/api_key"), + "missing api key create request: {:?}", + outcome.requests ); assert!( - requests + outcome + .requests .iter() .any(|request| request.contains("GET /v1/project?org_name=Target%20Org")), - "missing target org project request: {requests:?}" + "missing target org project request: {:?}", + outcome.requests ); - assert!(home + assert!(outcome + .repo + .path() + .join(".bt/skills/docs/sdk-install/_index.md") + .exists()); + assert!(outcome + .home .path() .join(".agents/skills/braintrust/SKILL.md") .exists()); - assert!(repo + assert!(outcome + .repo .path() .join(".agents/skills/braintrust/SKILL.md") .exists()); @@ -193,18 +296,6 @@ fn write_executable(path: &Path, content: &str) { fs::set_permissions(path, perms).expect("chmod"); } -fn seed_docs_cache(output_dir: &Path) { - fs::create_dir_all(output_dir.join("reference")).expect("create docs dir"); - fs::write(output_dir.join("README.md"), "# Docs\n").expect("write docs readme"); - fs::write(output_dir.join("reference").join("sql.md"), "# SQL\n").expect("write sql doc"); - for workflow in WORKFLOWS { - let workflow_dir = output_dir.join(workflow); - fs::create_dir_all(&workflow_dir).expect("create workflow dir"); - fs::write(workflow_dir.join("_index.md"), format!("# {workflow}\n")) - .expect("write workflow doc"); - } -} - struct PtyProcess { child: Box, writer: Box, @@ -340,12 +431,10 @@ fn wait_for_authorize_url(pty: &PtyProcess, browser_log: &Path, timeout: Duratio return url; } } - if let Some(url) = extract_authorize_url(&pty.snapshot()) { - return url; - } assert!( Instant::now() < deadline, - "timed out waiting for authorize url\n{}", + "timed out waiting for authorize url from browser log {}\n{}", + browser_log.display(), pty.snapshot() ); thread::sleep(Duration::from_millis(50)); From e91aa05bdfe46baf742687a66e43a33b0dc88167 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Halber?= Date: Thu, 30 Apr 2026 18:07:30 +0000 Subject: [PATCH 4/6] chore: update cargo.lock --- Cargo.lock | 44 ++++++++++++++++++++------------------------ 1 file changed, 20 insertions(+), 24 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b87a205..65b1121 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,7 +8,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f7b0a21988c1bf877cf4759ef5ddaac04c1c9fe808c9142ecb78ba97d97a28a" dependencies = [ - "bitflags", + "bitflags 2.11.0", "bytes", "futures-core", "futures-sink", @@ -30,7 +30,7 @@ dependencies = [ "actix-service", "actix-utils", "base64 0.22.1", - "bitflags", + "bitflags 2.11.0", "brotli", "bytes", "bytestring", @@ -396,6 +396,12 @@ dependencies = [ "ts-rs", ] +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.11.0" @@ -832,7 +838,7 @@ version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ - "bitflags", + "bitflags 2.11.0", "crossterm_winapi", "mio", "parking_lot", @@ -848,7 +854,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" dependencies = [ - "bitflags", + "bitflags 2.11.0", "crossterm_winapi", "document-features", "parking_lot", @@ -1832,7 +1838,7 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" dependencies = [ - "bitflags", + "bitflags 2.11.0", "libc", ] @@ -2221,7 +2227,7 @@ dependencies = [ "shared_library", "shell-words", "winapi", - "winreg 0.10.1", + "winreg", ] [[package]] @@ -2484,7 +2490,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" dependencies = [ - "bitflags", + "bitflags 2.11.0", "cassowary", "compact_str", "crossterm 0.28.1", @@ -2505,7 +2511,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.11.0", ] [[package]] @@ -2652,7 +2658,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags", + "bitflags 2.11.0", "errno", "libc", "linux-raw-sys 0.4.15", @@ -2665,7 +2671,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags", + "bitflags 2.11.0", "errno", "libc", "linux-raw-sys 0.12.1", @@ -2776,7 +2782,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags", + "bitflags 2.11.0", "core-foundation", "core-foundation-sys", "libc", @@ -3449,7 +3455,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ "async-compression", - "bitflags", + "bitflags 2.11.0", "bytes", "futures-core", "futures-util", @@ -3802,7 +3808,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags", + "bitflags 2.11.0", "hashbrown 0.15.5", "indexmap 2.13.0", "semver", @@ -4176,16 +4182,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "winreg" -version = "0.50.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" -dependencies = [ - "cfg-if", - "windows-sys 0.48.0", -] - [[package]] name = "wit-bindgen" version = "0.51.0" @@ -4244,7 +4240,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags", + "bitflags 2.11.0", "indexmap 2.13.0", "log", "serde", From 28eea32f7688ceef4cd5ebb93e3bf4c5b3840d70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Halber?= Date: Fri, 1 May 2026 21:36:49 +0000 Subject: [PATCH 5/6] chore: updated test to reflect new bt behavior --- tests/setup_e2e.rs | 213 +++++++++++++++++++++++++++++---------------- 1 file changed, 136 insertions(+), 77 deletions(-) diff --git a/tests/setup_e2e.rs b/tests/setup_e2e.rs index e93054f..3e11de5 100644 --- a/tests/setup_e2e.rs +++ b/tests/setup_e2e.rs @@ -29,64 +29,129 @@ const BRAINTRUST_CLI_ENVS: &[&str] = &[ ]; struct SetupRunOutcome { - repo: tempfile::TempDir, + _repo: tempfile::TempDir, home: tempfile::TempDir, _config_home: tempfile::TempDir, claude_log: PathBuf, codex_log: PathBuf, + cursor_agent_log: PathBuf, + gemini_log: PathBuf, + opencode_log: PathBuf, requests: Vec, } -#[test] -fn setup_supports_oauth_api_key_creation_and_explicit_claude_selection() { - let outcome = run_setup_flow("claude"); +struct AgentExpectation { + selector: &'static str, + log: fn(&SetupRunOutcome) -> &PathBuf, + log_contains: &'static [&'static str], + config_file: Option PathBuf>, +} + +fn run_agent_test(exp: AgentExpectation) { + let outcome = run_setup_flow(exp.selector); assert_common_setup_outcome(&outcome); - let claude_args = fs::read_to_string(&outcome.claude_log).expect("read claude log"); - assert!( - claude_args.contains("mcp add -s user --transport http braintrust"), - "{claude_args}" - ); - assert!( - claude_args.contains("Authorization: Bearer generated-api-key"), - "{claude_args}" - ); - assert!( - claude_args.contains("-p --permission-mode acceptEdits"), - "{claude_args}" - ); - assert!( - claude_args.contains("ENV=generated-api-key"), - "{claude_args}" - ); - assert!( - !outcome.codex_log.exists(), - "codex should not be invoked when Claude is selected" - ); - assert!(outcome.home.path().join(".claude/skills").exists()); + let log_path = (exp.log)(&outcome); + let log = fs::read_to_string(log_path) + .unwrap_or_else(|_| panic!("agent log not found: {}", log_path.display())); + for expected in exp.log_contains { + assert!( + log.contains(expected), + "expected {expected:?} in log:\n{log}" + ); + } + + if let Some(config_fn) = exp.config_file { + let path = config_fn(&outcome); + assert!(path.exists(), "config file not found: {}", path.display()); + } + + for other_log in [ + &outcome.claude_log, + &outcome.codex_log, + &outcome.cursor_agent_log, + &outcome.gemini_log, + &outcome.opencode_log, + ] { + if other_log != log_path { + assert!( + !other_log.exists(), + "unexpected agent log: {}", + other_log.display() + ); + } + } +} + +#[test] +fn setup_supports_oauth_api_key_creation_and_explicit_claude_selection() { + run_agent_test(AgentExpectation { + selector: "claude", + log: |o| &o.claude_log, + log_contains: &[ + "mcp add -s user --transport http braintrust", + "Authorization: Bearer generated-api-key", + "-p --permission-mode acceptEdits", + "ENV=generated-api-key", + ], + config_file: None, + }); } #[test] fn setup_supports_oauth_api_key_creation_and_explicit_codex_selection() { - let outcome = run_setup_flow("codex"); - assert_common_setup_outcome(&outcome); + run_agent_test(AgentExpectation { + selector: "codex", + log: |o| &o.codex_log, + log_contains: &[ + "mcp add braintrust --url", + "--bearer-token-env-var BRAINTRUST_API_KEY", + "exec -", + "ENV=generated-api-key", + ], + config_file: None, + }); +} - let codex_args = fs::read_to_string(&outcome.codex_log).expect("read codex log"); - assert!(codex_args.contains("mcp remove braintrust"), "{codex_args}"); - assert!( - codex_args.contains("mcp add braintrust --url"), - "{codex_args}" - ); - assert!( - codex_args.contains("--bearer-token-env-var BRAINTRUST_API_KEY"), - "{codex_args}" - ); - assert!(codex_args.contains("exec -"), "{codex_args}"); - assert!(codex_args.contains("ENV=generated-api-key"), "{codex_args}"); - assert!( - !outcome.claude_log.exists(), - "claude should not be invoked when Codex is selected" - ); +#[test] +fn setup_supports_oauth_api_key_creation_and_explicit_cursor_selection() { + run_agent_test(AgentExpectation { + selector: "cursor", + log: |o| &o.cursor_agent_log, + log_contains: &[ + "mcp enable braintrust", + "-p --output-format stream-json --stream-partial-output", + "ENV=generated-api-key", + ], + config_file: Some(|o| o.home.path().join(".cursor/mcp.json")), + }); +} + +#[test] +fn setup_supports_oauth_api_key_creation_and_explicit_gemini_selection() { + run_agent_test(AgentExpectation { + selector: "gemini", + log: |o| &o.gemini_log, + log_contains: &[ + "mcp add -s user --transport http braintrust", + "Authorization: Bearer generated-api-key", + "-p", + "--output-format stream-json", + "ENV=generated-api-key", + ], + config_file: None, + }); +} + +#[test] +fn setup_supports_oauth_api_key_creation_and_explicit_opencode_selection() { + run_agent_test(AgentExpectation { + selector: "opencode", + log: |o| &o.opencode_log, + // MCP for opencode is config-file only; the binary is only invoked for instrumentation + log_contains: &["run", "ENV=generated-api-key"], + config_file: Some(|o| o.home.path().join(".config/opencode/opencode.json")), + }); } fn run_setup_flow(selected_agent: &str) -> SetupRunOutcome { @@ -97,28 +162,31 @@ fn run_setup_flow(selected_agent: &str) -> SetupRunOutcome { let browser_log = home.path().join("opened-url.txt"); let claude_log = repo.path().join("claude.log"); let codex_log = repo.path().join("codex.log"); + let cursor_agent_log = repo.path().join("cursor-agent.log"); + let gemini_log = repo.path().join("gemini.log"); + let opencode_log = repo.path().join("opencode.log"); let path_env = format!( "{}:{}", bin_dir.path().display(), std::env::var("PATH").unwrap_or_default() ); - write_executable( - &bin_dir.path().join("claude"), - &format!( - "#!/bin/sh\nprintf '%s\\n' \"$*\" >> '{}'\nprintf 'ENV=%s\\n' \"$BRAINTRUST_API_KEY\" >> '{}'\nexit 0\n", - claude_log.display(), - claude_log.display() - ), - ); - write_executable( - &bin_dir.path().join("codex"), - &format!( - "#!/bin/sh\nprintf '%s\\n' \"$*\" >> '{}'\nprintf 'ENV=%s\\n' \"$BRAINTRUST_API_KEY\" >> '{}'\nexit 0\n", - codex_log.display(), - codex_log.display() - ), - ); + for (name, log) in [ + ("claude", &claude_log), + ("codex", &codex_log), + ("cursor-agent", &cursor_agent_log), + ("gemini", &gemini_log), + ("opencode", &opencode_log), + ] { + write_executable( + &bin_dir.path().join(name), + &format!( + "#!/bin/sh\nprintf '%s\\n' \"$*\" >> '{}'\nprintf 'ENV=%s\\n' \"$BRAINTRUST_API_KEY\" >> '{}'\nexit 0\n", + log.display(), + log.display() + ), + ); + } write_executable( &bin_dir.path().join("fake-browser"), &format!( @@ -190,6 +258,10 @@ fn run_setup_flow(selected_agent: &str) -> SetupRunOutcome { pty.send(selected_agent); pty.send("\r"); + pty.wait_for("Install reusable Braintrust coding-agent skills", TIMEOUT); + pty.send("n"); + pty.send("\r"); + let status = pty.wait(TIMEOUT); assert!( status.success(), @@ -213,11 +285,14 @@ fn run_setup_flow(selected_agent: &str) -> SetupRunOutcome { ); SetupRunOutcome { - repo, + _repo: repo, home, _config_home: config_home, claude_log, codex_log, + cursor_agent_log, + gemini_log, + opencode_log, requests: server.requests(), } } @@ -265,22 +340,6 @@ fn assert_common_setup_outcome(outcome: &SetupRunOutcome) { "missing target org project request: {:?}", outcome.requests ); - - assert!(outcome - .repo - .path() - .join(".bt/skills/docs/sdk-install/_index.md") - .exists()); - assert!(outcome - .home - .path() - .join(".agents/skills/braintrust/SKILL.md") - .exists()); - assert!(outcome - .repo - .path() - .join(".agents/skills/braintrust/SKILL.md") - .exists()); } fn make_git_repo() -> tempfile::TempDir { From bb4921fcb5117feadf357674b74d8446f6c0d7b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Halber?= Date: Mon, 4 May 2026 22:24:39 +0000 Subject: [PATCH 6/6] chore: clean up PtyProcess resources in case of panic --- tests/setup_e2e.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/setup_e2e.rs b/tests/setup_e2e.rs index 3e11de5..5dbac93 100644 --- a/tests/setup_e2e.rs +++ b/tests/setup_e2e.rs @@ -482,6 +482,15 @@ impl PtyProcess { } } +impl Drop for PtyProcess { + fn drop(&mut self) { + let _ = self.child.kill(); + if let Some(thread) = self.reader_thread.take() { + let _ = thread.join(); + } + } +} + fn wait_for_authorize_url(pty: &PtyProcess, browser_log: &Path, timeout: Duration) -> String { let deadline = Instant::now() + timeout; loop {