Download codex-rs/core/tests/suite/cli_stream.rs from SaylorTwift/codex: direct link, hf CLI and curl.
- Browser
- Download file 28.3 kB
-
https://huggingface.co/SaylorTwift/codex/resolve/main/codex-rs/core/tests/suite/cli_stream.rs
- Command line
-
hf download hf://SaylorTwift/codex/codex-rs/core/tests/suite/cli_stream.rs
-
curl -L -o cli_stream.rs https://huggingface.co/SaylorTwift/codex/resolve/main/codex-rs/core/tests/suite/cli_stream.rs
28.3 kB
| use codex_git_utils::SanitizedGitUrl; | |
| use codex_git_utils::collect_git_info; | |
| use codex_login::CODEX_ACCESS_TOKEN_ENV_VAR; | |
| use codex_login::CODEX_API_KEY_ENV_VAR; | |
| use codex_protocol::protocol::GitInfo; | |
| use core_test_support::fs_wait; | |
| use core_test_support::responses; | |
| use core_test_support::skip_if_no_network; | |
| use pretty_assertions::assert_eq; | |
| use std::io; | |
| use std::os::unix::process::CommandExt as _; | |
| use std::process::Command; | |
| use std::process::Output; | |
| use std::process::Stdio; | |
| use std::sync::mpsc; | |
| use std::thread; | |
| use std::time::Duration; | |
| use tempfile::TempDir; | |
| use uuid::Uuid; | |
| use wiremock::Mock; | |
| use wiremock::MockServer; | |
| use wiremock::ResponseTemplate; | |
| use wiremock::matchers::header; | |
| use wiremock::matchers::method; | |
| use wiremock::matchers::path; | |
| const PERSONAL_ACCESS_TOKEN: &str = "at-cli-test"; | |
| const PERSONAL_ACCESS_TOKEN_AUTHORIZATION: &str = "Bearer at-cli-test"; | |
| const PERSONAL_ACCESS_TOKEN_ACCOUNT_ID: &str = "account-pat"; | |
| const WHOAMI_PATH: &str = "/v1/user-auth-credential/whoami"; | |
| const CLOUD_CONFIG_BUNDLE_PATH: &str = "/backend-api/wham/config/bundle"; | |
| const CLI_TIMEOUT: Duration = Duration::from_secs(30); | |
| fn repo_root() -> std::path::PathBuf { | |
| codex_utils_cargo_bin::repo_root().expect("failed to resolve repo root") | |
| } | |
| fn cli_sse_response() -> String { | |
| responses::sse(vec![ | |
| responses::ev_response_created("resp-fixture"), | |
| responses::ev_assistant_message("msg-fixture", "fixture hello"), | |
| responses::ev_completed("resp-fixture"), | |
| ]) | |
| } | |
| async fn mount_personal_access_token_startup(server: &MockServer) { | |
| Mock::given(method("GET")) | |
| .and(path(WHOAMI_PATH)) | |
| .and(header("authorization", PERSONAL_ACCESS_TOKEN_AUTHORIZATION)) | |
| .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ | |
| "email": "user@example.com", | |
| "chatgpt_user_id": "user-pat", | |
| "chatgpt_account_id": PERSONAL_ACCESS_TOKEN_ACCOUNT_ID, | |
| "chatgpt_plan_type": "enterprise", | |
| "chatgpt_account_is_fedramp": true, | |
| }))) | |
| .expect(1..) | |
| .mount(server) | |
| .await; | |
| Mock::given(method("GET")) | |
| .and(path(CLOUD_CONFIG_BUNDLE_PATH)) | |
| .and(header("authorization", PERSONAL_ACCESS_TOKEN_AUTHORIZATION)) | |
| .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({}))) | |
| .expect(1) | |
| .mount(server) | |
| .await; | |
| } | |
| fn personal_access_token_exec_command(server: &MockServer, home: &TempDir) -> Command { | |
| let bin = codex_utils_cargo_bin::cargo_bin("codex").unwrap(); | |
| let mut cmd = Command::new(bin); | |
| cmd.arg("exec") | |
| .arg("--skip-git-repo-check") | |
| .arg("-c") | |
| .arg(format!("openai_base_url=\"{}/api/codex\"", server.uri())) | |
| .arg("-c") | |
| .arg(format!("chatgpt_base_url=\"{}/backend-api\"", server.uri())) | |
| .arg("-C") | |
| .arg(repo_root()) | |
| .arg("hello?"); | |
| cmd.env("CODEX_HOME", home.path()) | |
| .env(CODEX_ACCESS_TOKEN_ENV_VAR, PERSONAL_ACCESS_TOKEN) | |
| .env("CODEX_AUTHAPI_BASE_URL", server.uri()) | |
| .env_remove(CODEX_API_KEY_ENV_VAR) | |
| .env_remove("OPENAI_API_KEY"); | |
| cmd | |
| } | |
| struct ChildProcessCleanupGuard(u32); | |
| impl Drop for ChildProcessCleanupGuard { | |
| fn drop(&mut self) { | |
| { | |
| let _ = codex_utils_pty::process_group::kill_process_group(self.0); | |
| } | |
| { | |
| let _ = Command::new("taskkill") | |
| .args(["/PID", &self.0.to_string(), "/T", "/F"]) | |
| .stdin(Stdio::null()) | |
| .stdout(Stdio::null()) | |
| .stderr(Stdio::null()) | |
| .status(); | |
| } | |
| { | |
| let _ = self.0; | |
| } | |
| } | |
| } | |
| // Use this for new `codex exec` subprocess tests in this file. These commands | |
| // can spawn shell/Python grandchildren, so the timeout path must reap the whole | |
| // process group instead of only the direct CLI child. | |
| fn run_cli_command(command: &mut Command) -> io::Result<Output> { | |
| command.process_group(0); | |
| command | |
| .stdin(Stdio::null()) | |
| .stdout(Stdio::piped()) | |
| .stderr(Stdio::piped()); | |
| let child = command.spawn()?; | |
| let _cleanup = ChildProcessCleanupGuard(child.id()); | |
| let (sender, receiver) = mpsc::sync_channel(1); | |
| let _waiter = thread::spawn(move || { | |
| let _ = sender.send(child.wait_with_output()); | |
| }); | |
| match receiver.recv_timeout(CLI_TIMEOUT) { | |
| Ok(output) => output, | |
| Err(mpsc::RecvTimeoutError::Timeout) => { | |
| Err(io::Error::new(io::ErrorKind::TimedOut, "process timed out")) | |
| } | |
| Err(mpsc::RecvTimeoutError::Disconnected) => { | |
| Err(io::Error::other("process output reader thread exited")) | |
| } | |
| } | |
| } | |
| async fn responses_mode_stream_cli_supports_personal_access_tokens() { | |
| skip_if_no_network!(); | |
| let server = MockServer::start().await; | |
| mount_personal_access_token_startup(&server).await; | |
| let resp_mock = responses::mount_sse_once(&server, cli_sse_response()).await; | |
| let home = TempDir::new().unwrap(); | |
| let mut cmd = personal_access_token_exec_command(&server, &home); | |
| let output = run_cli_command(&mut cmd).unwrap(); | |
| assert!( | |
| output.status.success(), | |
| "codex-cli exec failed: {}", | |
| String::from_utf8_lossy(&output.stderr) | |
| ); | |
| let request = resp_mock.single_request(); | |
| assert_eq!(request.path(), "/api/codex/responses"); | |
| assert_eq!( | |
| request.header("authorization").as_deref(), | |
| Some("Bearer at-cli-test") | |
| ); | |
| assert_eq!( | |
| request.header("chatgpt-account-id").as_deref(), | |
| Some(PERSONAL_ACCESS_TOKEN_ACCOUNT_ID) | |
| ); | |
| assert_eq!(request.header("x-openai-fedramp").as_deref(), Some("true")); | |
| server.verify().await; | |
| } | |
| async fn responses_mode_stream_cli_does_not_attempt_oauth_refresh_for_personal_access_tokens_after_401() | |
| { | |
| skip_if_no_network!(); | |
| let server = MockServer::start().await; | |
| mount_personal_access_token_startup(&server).await; | |
| Mock::given(method("POST")) | |
| .and(path("/api/codex/responses")) | |
| .and(header("authorization", PERSONAL_ACCESS_TOKEN_AUTHORIZATION)) | |
| .and(header( | |
| "chatgpt-account-id", | |
| PERSONAL_ACCESS_TOKEN_ACCOUNT_ID, | |
| )) | |
| .and(header("x-openai-fedramp", "true")) | |
| .respond_with(ResponseTemplate::new(401).set_body_string("unauthorized")) | |
| .expect(1..) | |
| .mount(&server) | |
| .await; | |
| Mock::given(method("POST")) | |
| .and(path("/oauth/token")) | |
| .respond_with(ResponseTemplate::new(500)) | |
| .expect(0) | |
| .mount(&server) | |
| .await; | |
| let home = TempDir::new().unwrap(); | |
| let mut cmd = personal_access_token_exec_command(&server, &home); | |
| let output = run_cli_command(&mut cmd).unwrap(); | |
| assert!(!output.status.success()); | |
| server.verify().await; | |
| } | |
| /// Tests streaming the Responses API through the CLI using a mock server. | |
| async fn responses_mode_stream_cli() { | |
| skip_if_no_network!(); | |
| let server = MockServer::start().await; | |
| let repo_root = repo_root(); | |
| let sse = responses::sse(vec![ | |
| responses::ev_response_created("resp-1"), | |
| responses::ev_assistant_message("msg-1", "hi"), | |
| responses::ev_completed("resp-1"), | |
| ]); | |
| let resp_mock = responses::mount_sse_once(&server, sse).await; | |
| let home = TempDir::new().unwrap(); | |
| let provider_override = format!( | |
| "model_providers.mock={{ name = \"mock\", base_url = \"{}/v1\", env_key = \"PATH\", wire_api = \"responses\" }}", | |
| server.uri() | |
| ); | |
| let bin = codex_utils_cargo_bin::cargo_bin("codex").unwrap(); | |
| let mut cmd = Command::new(bin); | |
| cmd.arg("exec") | |
| .arg("--skip-git-repo-check") | |
| .arg("-c") | |
| .arg(&provider_override) | |
| .arg("-c") | |
| .arg("model_provider=\"mock\"") | |
| .arg("-C") | |
| .arg(&repo_root) | |
| .arg("hello?"); | |
| cmd.env("CODEX_HOME", home.path()) | |
| .env("OPENAI_API_KEY", "dummy"); | |
| let output = run_cli_command(&mut cmd).unwrap(); | |
| println!("Status: {}", output.status); | |
| println!("Stdout:\n{}", String::from_utf8_lossy(&output.stdout)); | |
| println!("Stderr:\n{}", String::from_utf8_lossy(&output.stderr)); | |
| assert!(output.status.success()); | |
| let stdout = String::from_utf8_lossy(&output.stdout); | |
| let hi_lines = stdout.lines().filter(|line| line.trim() == "hi").count(); | |
| assert_eq!(hi_lines, 1, "Expected exactly one line with 'hi'"); | |
| let request = resp_mock.single_request(); | |
| assert_eq!(request.path(), "/v1/responses"); | |
| } | |
| /// Ensures `openai_base_url` config override routes built-in openai provider requests. | |
| async fn responses_mode_stream_cli_supports_openai_base_url_config_override() { | |
| skip_if_no_network!(); | |
| let server = MockServer::start().await; | |
| let repo_root = repo_root(); | |
| let sse = responses::sse(vec![ | |
| responses::ev_response_created("resp-1"), | |
| responses::ev_assistant_message("msg-1", "hi"), | |
| responses::ev_completed("resp-1"), | |
| ]); | |
| let resp_mock = responses::mount_sse_once(&server, sse).await; | |
| let home = TempDir::new().unwrap(); | |
| let bin = codex_utils_cargo_bin::cargo_bin("codex").unwrap(); | |
| let mut cmd = Command::new(bin); | |
| cmd.arg("exec") | |
| .arg("--skip-git-repo-check") | |
| .arg("-c") | |
| .arg(format!("openai_base_url=\"{}/v1\"", server.uri())) | |
| .arg("-C") | |
| .arg(&repo_root) | |
| .arg("hello?"); | |
| cmd.env("CODEX_HOME", home.path()) | |
| .env("OPENAI_API_KEY", "dummy"); | |
| let output = run_cli_command(&mut cmd).unwrap(); | |
| assert!(output.status.success()); | |
| let request = resp_mock.single_request(); | |
| assert_eq!(request.path(), "/v1/responses"); | |
| } | |
| /// Verify that passing `-c model_instructions_file=...` to the CLI | |
| /// overrides the built-in base instructions by inspecting the request body | |
| /// received by a mock OpenAI Responses endpoint. | |
| async fn exec_cli_applies_model_instructions_file() { | |
| skip_if_no_network!(); | |
| // Start mock server which will capture the request and return a minimal | |
| // SSE stream for a single turn. | |
| let server = MockServer::start().await; | |
| let sse = concat!( | |
| "data: {\"type\":\"response.created\",\"response\":{}}\n\n", | |
| "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"r1\"}}\n\n" | |
| ); | |
| let resp_mock = core_test_support::responses::mount_sse_once(&server, sse.to_string()).await; | |
| // Create a temporary instructions file with a unique marker we can assert | |
| // appears in the outbound request payload. | |
| let custom = TempDir::new().unwrap(); | |
| let marker = "cli-model-instructions-file-marker"; | |
| let custom_path = custom.path().join("instr.md"); | |
| std::fs::write(&custom_path, marker).unwrap(); | |
| let custom_path_str = custom_path.to_string_lossy().replace('\\', "/"); | |
| // Build a provider override that points at the mock server and instructs | |
| // Codex to use the Responses API with the dummy env var. | |
| let provider_override = format!( | |
| "model_providers.mock={{ name = \"mock\", base_url = \"{}/v1\", env_key = \"PATH\", wire_api = \"responses\" }}", | |
| server.uri() | |
| ); | |
| let home = TempDir::new().unwrap(); | |
| let repo_root = repo_root(); | |
| let bin = codex_utils_cargo_bin::cargo_bin("codex").unwrap(); | |
| let mut cmd = Command::new(bin); | |
| cmd.arg("exec") | |
| .arg("--skip-git-repo-check") | |
| .arg("--model") | |
| .arg("gpt-5.5") | |
| .arg("-c") | |
| .arg(&provider_override) | |
| .arg("-c") | |
| .arg("model_provider=\"mock\"") | |
| .arg("-c") | |
| .arg(format!("model_instructions_file=\"{custom_path_str}\"")) | |
| .arg("-C") | |
| .arg(&repo_root) | |
| .arg("hello?\n"); | |
| cmd.env("CODEX_HOME", home.path()) | |
| .env("OPENAI_API_KEY", "dummy"); | |
| let output = run_cli_command(&mut cmd).unwrap(); | |
| println!("Status: {}", output.status); | |
| println!("Stdout:\n{}", String::from_utf8_lossy(&output.stdout)); | |
| println!("Stderr:\n{}", String::from_utf8_lossy(&output.stderr)); | |
| assert!(output.status.success()); | |
| // Inspect the captured request and verify our custom base instructions were | |
| // included in the `instructions` field. | |
| let request = resp_mock.single_request(); | |
| let body = request.body_json(); | |
| let instructions = body | |
| .get("instructions") | |
| .and_then(|v| v.as_str()) | |
| .unwrap_or_default() | |
| .to_string(); | |
| assert!( | |
| instructions.contains(marker), | |
| "instructions did not contain custom marker; got: {instructions}" | |
| ); | |
| } | |
| /// Verify that `codex exec --profile ...` preserves the active user config | |
| /// profile when it starts the in-process app-server thread, so the selected | |
| /// profile's `model_instructions_file` reaches the outbound request. | |
| async fn exec_cli_profile_applies_model_instructions_file() { | |
| skip_if_no_network!(); | |
| let server = MockServer::start().await; | |
| let sse = concat!( | |
| "data: {\"type\":\"response.created\",\"response\":{}}\n\n", | |
| "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"r1\"}}\n\n" | |
| ); | |
| let resp_mock = core_test_support::responses::mount_sse_once(&server, sse.to_string()).await; | |
| let custom = TempDir::new().unwrap(); | |
| let marker = "cli-profile-model-instructions-file-marker"; | |
| let custom_path = custom.path().join("instr.md"); | |
| std::fs::write(&custom_path, marker).unwrap(); | |
| let custom_path_str = custom_path.to_string_lossy().replace('\\', "/"); | |
| let provider_override = format!( | |
| "model_providers.mock={{ name = \"mock\", base_url = \"{}/v1\", env_key = \"PATH\", wire_api = \"responses\" }}", | |
| server.uri() | |
| ); | |
| let home = TempDir::new().unwrap(); | |
| std::fs::write( | |
| home.path().join("default.config.toml"), | |
| format!("model_instructions_file = \"{custom_path_str}\"\n"), | |
| ) | |
| .unwrap(); | |
| let repo_root = repo_root(); | |
| let bin = codex_utils_cargo_bin::cargo_bin("codex").unwrap(); | |
| let mut cmd = Command::new(bin); | |
| cmd.arg("exec") | |
| .arg("--skip-git-repo-check") | |
| .arg("--model") | |
| .arg("gpt-5.5") | |
| .arg("--profile") | |
| .arg("default") | |
| .arg("-c") | |
| .arg(&provider_override) | |
| .arg("-c") | |
| .arg("model_provider=\"mock\"") | |
| .arg("-C") | |
| .arg(&repo_root) | |
| .arg("hello?\n"); | |
| cmd.env("CODEX_HOME", home.path()) | |
| .env("OPENAI_API_KEY", "dummy"); | |
| let output = run_cli_command(&mut cmd).unwrap(); | |
| println!("Status: {}", output.status); | |
| println!("Stdout:\n{}", String::from_utf8_lossy(&output.stdout)); | |
| println!("Stderr:\n{}", String::from_utf8_lossy(&output.stderr)); | |
| assert!(output.status.success()); | |
| let request = resp_mock.single_request(); | |
| let body = request.body_json(); | |
| let instructions = body | |
| .get("instructions") | |
| .and_then(|v| v.as_str()) | |
| .unwrap_or_default() | |
| .to_string(); | |
| assert!( | |
| instructions.contains(marker), | |
| "instructions did not contain profile marker; got: {instructions}" | |
| ); | |
| } | |
| /// Tests streaming responses through the CLI using a local Responses API server. | |
| async fn responses_api_stream_cli() { | |
| skip_if_no_network!(); | |
| let server = MockServer::start().await; | |
| let resp_mock = responses::mount_sse_once(&server, cli_sse_response()).await; | |
| let repo_root = repo_root(); | |
| let home = TempDir::new().unwrap(); | |
| let bin = codex_utils_cargo_bin::cargo_bin("codex").unwrap(); | |
| let mut cmd = Command::new(bin); | |
| cmd.arg("exec") | |
| .arg("--skip-git-repo-check") | |
| .arg("-c") | |
| .arg(format!("openai_base_url=\"{}/v1\"", server.uri())) | |
| .arg("-C") | |
| .arg(&repo_root) | |
| .arg("hello?"); | |
| cmd.env("CODEX_HOME", home.path()) | |
| .env("OPENAI_API_KEY", "dummy"); | |
| let output = run_cli_command(&mut cmd).unwrap(); | |
| assert!(output.status.success()); | |
| let stdout = String::from_utf8_lossy(&output.stdout); | |
| assert!(stdout.contains("fixture hello")); | |
| let request = resp_mock.single_request(); | |
| assert_eq!(request.path(), "/v1/responses"); | |
| } | |
| /// End-to-end: create a session (writes rollout), verify the file, then resume and confirm append. | |
| async fn integration_creates_and_checks_session_file() -> anyhow::Result<()> { | |
| // Honor sandbox network restrictions for CI parity with the other tests. | |
| skip_if_no_network!(Ok(())); | |
| // 1. Temp home so we read/write isolated session files. | |
| let home = TempDir::new()?; | |
| // 2. Unique marker we'll look for in the session log. | |
| let marker = format!("integration-test-{}", Uuid::new_v4()); | |
| let prompt = format!("echo {marker}"); | |
| // 3. Serve two hermetic SSE responses, one for the initial run and one for resume. | |
| let server = MockServer::start().await; | |
| let resp_mock = | |
| responses::mount_sse_sequence(&server, vec![cli_sse_response(), cli_sse_response()]).await; | |
| let repo_root = repo_root(); | |
| // 4. Run the codex CLI and invoke `exec`, which is what records a session. | |
| let bin = codex_utils_cargo_bin::cargo_bin("codex").unwrap(); | |
| let mut cmd = Command::new(bin); | |
| cmd.arg("exec") | |
| .arg("--skip-git-repo-check") | |
| .arg("-c") | |
| .arg(format!("openai_base_url=\"{}/v1\"", server.uri())) | |
| .arg("-C") | |
| .arg(&repo_root) | |
| .arg(&prompt); | |
| cmd.env("CODEX_HOME", home.path()) | |
| .env(CODEX_API_KEY_ENV_VAR, "dummy"); | |
| let output = run_cli_command(&mut cmd).unwrap(); | |
| assert!( | |
| output.status.success(), | |
| "codex-cli exec failed: {}", | |
| String::from_utf8_lossy(&output.stderr) | |
| ); | |
| // Wait for sessions dir to appear. | |
| let sessions_dir = home.path().join("sessions"); | |
| fs_wait::wait_for_path_exists(&sessions_dir, Duration::from_secs(5)).await?; | |
| // Find the session file that contains `marker`. | |
| let marker_clone = marker.clone(); | |
| let path = fs_wait::wait_for_matching_file(&sessions_dir, Duration::from_secs(10), move |p| { | |
| if p.extension().and_then(|ext| ext.to_str()) != Some("jsonl") { | |
| return false; | |
| } | |
| let Ok(content) = std::fs::read_to_string(p) else { | |
| return false; | |
| }; | |
| content.contains(&marker_clone) | |
| }) | |
| .await?; | |
| // Basic sanity checks on location and metadata. | |
| let rel = path | |
| .strip_prefix(&sessions_dir) | |
| .expect("session file should live under sessions/"); | |
| let comps: Vec<String> = rel | |
| .components() | |
| .map(|c| c.as_os_str().to_string_lossy().into_owned()) | |
| .collect(); | |
| assert_eq!( | |
| comps.len(), | |
| 4, | |
| "Expected sessions/YYYY/MM/DD/<file>, got {rel:?}" | |
| ); | |
| let year = &comps[0]; | |
| let month = &comps[1]; | |
| let day = &comps[2]; | |
| assert!( | |
| year.len() == 4 && year.chars().all(|c| c.is_ascii_digit()), | |
| "Year dir not 4-digit numeric: {year}" | |
| ); | |
| assert!( | |
| month.len() == 2 && month.chars().all(|c| c.is_ascii_digit()), | |
| "Month dir not zero-padded 2-digit numeric: {month}" | |
| ); | |
| assert!( | |
| day.len() == 2 && day.chars().all(|c| c.is_ascii_digit()), | |
| "Day dir not zero-padded 2-digit numeric: {day}" | |
| ); | |
| if let Ok(m) = month.parse::<u8>() { | |
| assert!((1..=12).contains(&m), "Month out of range: {m}"); | |
| } | |
| if let Ok(d) = day.parse::<u8>() { | |
| assert!((1..=31).contains(&d), "Day out of range: {d}"); | |
| } | |
| let content = std::fs::read_to_string(&path).expect("failed to read session file"); | |
| let mut lines = content.lines(); | |
| let meta_line = lines | |
| .next() | |
| .ok_or("missing session meta line") | |
| .expect("missing session meta line"); | |
| let meta: serde_json::Value = | |
| serde_json::from_str(meta_line).expect("failed to parse session meta line as JSON"); | |
| assert_eq!( | |
| meta.get("type").and_then(|v| v.as_str()), | |
| Some("session_meta") | |
| ); | |
| let payload = meta.get("payload").expect("Missing payload in meta line"); | |
| assert!(payload.get("id").is_some(), "SessionMeta missing id"); | |
| assert!( | |
| payload.get("timestamp").is_some(), | |
| "SessionMeta missing timestamp" | |
| ); | |
| let mut found_message = false; | |
| for line in lines { | |
| if line.trim().is_empty() { | |
| continue; | |
| } | |
| let Ok(item) = serde_json::from_str::<serde_json::Value>(line) else { | |
| continue; | |
| }; | |
| if item.get("type").and_then(|t| t.as_str()) == Some("response_item") | |
| && let Some(payload) = item.get("payload") | |
| && payload.get("type").and_then(|t| t.as_str()) == Some("message") | |
| && let Some(c) = payload.get("content") | |
| && c.to_string().contains(&marker) | |
| { | |
| found_message = true; | |
| break; | |
| } | |
| } | |
| assert!( | |
| found_message, | |
| "No message found in session file containing the marker" | |
| ); | |
| // Second run: resume should update the existing file. | |
| let marker2 = format!("integration-resume-{}", Uuid::new_v4()); | |
| let prompt2 = format!("echo {marker2}"); | |
| let bin2 = codex_utils_cargo_bin::cargo_bin("codex").unwrap(); | |
| let mut cmd2 = Command::new(bin2); | |
| cmd2.arg("exec") | |
| .arg("--skip-git-repo-check") | |
| .arg("-c") | |
| .arg(format!("openai_base_url=\"{}/v1\"", server.uri())) | |
| .arg("-C") | |
| .arg(&repo_root) | |
| .arg(&prompt2) | |
| .arg("resume") | |
| .arg("--last"); | |
| cmd2.env("CODEX_HOME", home.path()) | |
| .env("OPENAI_API_KEY", "dummy"); | |
| let output2 = run_cli_command(&mut cmd2).unwrap(); | |
| assert!(output2.status.success(), "resume codex-cli run failed"); | |
| assert_eq!(resp_mock.requests().len(), 2); | |
| // Find the new session file containing the resumed marker. | |
| let marker2_clone = marker2.clone(); | |
| let resumed_path = | |
| fs_wait::wait_for_matching_file(&sessions_dir, Duration::from_secs(10), move |p| { | |
| if p.extension().and_then(|ext| ext.to_str()) != Some("jsonl") { | |
| return false; | |
| } | |
| std::fs::read_to_string(p) | |
| .map(|content| content.contains(&marker2_clone)) | |
| .unwrap_or(false) | |
| }) | |
| .await?; | |
| // Resume should write to the existing log file. | |
| assert_eq!( | |
| resumed_path, path, | |
| "resume should create a new session file" | |
| ); | |
| let resumed_content = std::fs::read_to_string(&resumed_path)?; | |
| assert!( | |
| resumed_content.contains(&marker), | |
| "resumed file missing original marker" | |
| ); | |
| assert!( | |
| resumed_content.contains(&marker2), | |
| "resumed file missing resumed marker" | |
| ); | |
| Ok(()) | |
| } | |
| /// Integration test to verify git info is collected and recorded in session files. | |
| async fn integration_git_info_unit_test() { | |
| // This test verifies git info collection works independently | |
| // without depending on the full CLI integration | |
| // 1. Create temp directory for git repo | |
| let temp_dir = TempDir::new().unwrap(); | |
| let git_repo = temp_dir.path().to_path_buf(); | |
| let envs = vec![ | |
| ("GIT_CONFIG_GLOBAL", "/dev/null"), | |
| ("GIT_CONFIG_NOSYSTEM", "1"), | |
| ]; | |
| // 2. Initialize a git repository with some content | |
| let init_output = std::process::Command::new("git") | |
| .envs(envs.clone()) | |
| .args(["init"]) | |
| .current_dir(&git_repo) | |
| .output() | |
| .unwrap(); | |
| assert!(init_output.status.success(), "git init failed"); | |
| // Configure git user (required for commits) | |
| std::process::Command::new("git") | |
| .envs(envs.clone()) | |
| .args(["config", "user.name", "Integration Test"]) | |
| .current_dir(&git_repo) | |
| .output() | |
| .unwrap(); | |
| std::process::Command::new("git") | |
| .envs(envs.clone()) | |
| .args(["config", "user.email", "test@example.com"]) | |
| .current_dir(&git_repo) | |
| .output() | |
| .unwrap(); | |
| // Create a test file and commit it | |
| let test_file = git_repo.join("test.txt"); | |
| std::fs::write(&test_file, "integration test content").unwrap(); | |
| std::process::Command::new("git") | |
| .envs(envs.clone()) | |
| .args(["add", "."]) | |
| .current_dir(&git_repo) | |
| .output() | |
| .unwrap(); | |
| let commit_output = std::process::Command::new("git") | |
| .envs(envs.clone()) | |
| .args(["commit", "-m", "Integration test commit"]) | |
| .current_dir(&git_repo) | |
| .output() | |
| .unwrap(); | |
| assert!(commit_output.status.success(), "git commit failed"); | |
| // Create a branch to test branch detection | |
| std::process::Command::new("git") | |
| .envs(envs.clone()) | |
| .args(["checkout", "-b", "integration-test-branch"]) | |
| .current_dir(&git_repo) | |
| .output() | |
| .unwrap(); | |
| // Add a remote to test repository URL detection | |
| std::process::Command::new("git") | |
| .envs(envs.clone()) | |
| .args([ | |
| "remote", | |
| "add", | |
| "origin", | |
| "https://github.com/example/integration-test.git", | |
| ]) | |
| .current_dir(&git_repo) | |
| .output() | |
| .unwrap(); | |
| // 3. Test git info collection directly | |
| let git_info = collect_git_info(&git_repo).await; | |
| // 4. Verify git info is present and contains expected data | |
| assert!(git_info.is_some(), "Git info should be collected"); | |
| let git_info = git_info.unwrap(); | |
| // Check that we have a commit hash | |
| assert!( | |
| git_info.commit_hash.is_some(), | |
| "Git info should contain commit_hash" | |
| ); | |
| let commit_hash = &git_info.commit_hash.as_ref().unwrap().0; | |
| assert_eq!(commit_hash.len(), 40, "Commit hash should be 40 characters"); | |
| assert!( | |
| commit_hash.chars().all(|c| c.is_ascii_hexdigit()), | |
| "Commit hash should be hexadecimal" | |
| ); | |
| // Check that we have the correct branch | |
| assert!(git_info.branch.is_some(), "Git info should contain branch"); | |
| let branch = git_info.branch.as_ref().unwrap(); | |
| assert_eq!( | |
| branch, "integration-test-branch", | |
| "Branch should match what we created" | |
| ); | |
| // Check that we have the repository URL | |
| assert!( | |
| git_info.repository_url.is_some(), | |
| "Git info should contain repository_url" | |
| ); | |
| let repo_url = git_info.repository_url.as_ref().unwrap(); | |
| // Some hosts rewrite remotes (e.g., github.com → git@github.com), so assert against | |
| // the actual remote reported by git instead of a static URL. | |
| let expected_remote_url = std::process::Command::new("git") | |
| .args(["remote", "get-url", "origin"]) | |
| .current_dir(&git_repo) | |
| .output() | |
| .unwrap(); | |
| let expected_remote_url = String::from_utf8(expected_remote_url.stdout) | |
| .unwrap() | |
| .trim() | |
| .to_string(); | |
| let expected_remote_url = SanitizedGitUrl::try_from(expected_remote_url.as_str()).unwrap(); | |
| assert_eq!( | |
| repo_url, &expected_remote_url, | |
| "Repository URL should match git remote get-url output" | |
| ); | |
| println!("✅ Git info collection test passed!"); | |
| println!(" Commit: {commit_hash}"); | |
| println!(" Branch: {branch}"); | |
| println!(" Repo: {repo_url}"); | |
| // 5. Test serialization to ensure it works in SessionMeta | |
| let serialized = serde_json::to_string(&git_info).unwrap(); | |
| let deserialized: GitInfo = serde_json::from_str(&serialized).unwrap(); | |
| assert_eq!(git_info.commit_hash, deserialized.commit_hash); | |
| assert_eq!(git_info.branch, deserialized.branch); | |
| assert_eq!( | |
| git_info | |
| .repository_url | |
| .as_ref() | |
| .map(SanitizedGitUrl::as_str), | |
| deserialized.repository_url.as_deref() | |
| ); | |
| println!("✅ Git info serialization test passed!"); | |
| } | |