276 lines
9.8 KiB
Rust
276 lines
9.8 KiB
Rust
#![expect(clippy::unwrap_used)]
|
|
|
|
use assert_cmd::Command as AssertCommand;
|
|
use codex_core::exec::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR;
|
|
use serde_json::Value;
|
|
use std::time::Duration;
|
|
use std::time::Instant;
|
|
use tempfile::TempDir;
|
|
use uuid::Uuid;
|
|
use walkdir::WalkDir;
|
|
use wiremock::Mock;
|
|
use wiremock::MockServer;
|
|
use wiremock::ResponseTemplate;
|
|
use wiremock::matchers::method;
|
|
use wiremock::matchers::path;
|
|
|
|
/// Tests streaming chat completions through the CLI using a mock server.
|
|
/// This test:
|
|
/// 1. Sets up a mock server that simulates OpenAI's chat completions API
|
|
/// 2. Configures codex to use this mock server via a custom provider
|
|
/// 3. Sends a simple "hello?" prompt and verifies the streamed response
|
|
/// 4. Ensures the response is received exactly once and contains "hi"
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn chat_mode_stream_cli() {
|
|
if std::env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() {
|
|
println!(
|
|
"Skipping test because it cannot execute when network is disabled in a Codex sandbox."
|
|
);
|
|
return;
|
|
}
|
|
|
|
let server = MockServer::start().await;
|
|
let sse = concat!(
|
|
"data: {\"choices\":[{\"delta\":{\"content\":\"hi\"}}]}\n\n",
|
|
"data: {\"choices\":[{\"delta\":{}}]}\n\n",
|
|
"data: [DONE]\n\n"
|
|
);
|
|
Mock::given(method("POST"))
|
|
.and(path("/v1/chat/completions"))
|
|
.respond_with(
|
|
ResponseTemplate::new(200)
|
|
.insert_header("content-type", "text/event-stream")
|
|
.set_body_raw(sse, "text/event-stream"),
|
|
)
|
|
.expect(1)
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let home = TempDir::new().unwrap();
|
|
let provider_override = format!(
|
|
"model_providers.mock={{ name = \"mock\", base_url = \"{}/v1\", env_key = \"PATH\", wire_api = \"chat\" }}",
|
|
server.uri()
|
|
);
|
|
let mut cmd = AssertCommand::new("cargo");
|
|
cmd.arg("run")
|
|
.arg("-p")
|
|
.arg("codex-cli")
|
|
.arg("--quiet")
|
|
.arg("--")
|
|
.arg("exec")
|
|
.arg("--skip-git-repo-check")
|
|
.arg("-c")
|
|
.arg(&provider_override)
|
|
.arg("-c")
|
|
.arg("model_provider=\"mock\"")
|
|
.arg("-C")
|
|
.arg(env!("CARGO_MANIFEST_DIR"))
|
|
.arg("hello?");
|
|
cmd.env("CODEX_HOME", home.path())
|
|
.env("OPENAI_API_KEY", "dummy")
|
|
.env("OPENAI_BASE_URL", format!("{}/v1", server.uri()));
|
|
|
|
let output = cmd.output().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'");
|
|
|
|
server.verify().await;
|
|
}
|
|
|
|
/// Tests streaming responses through the CLI using a local SSE fixture file.
|
|
/// This test:
|
|
/// 1. Uses a pre-recorded SSE response fixture instead of a live server
|
|
/// 2. Configures codex to read from this fixture via CODEX_RS_SSE_FIXTURE env var
|
|
/// 3. Sends a "hello?" prompt and verifies the response
|
|
/// 4. Ensures the fixture content is correctly streamed through the CLI
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn responses_api_stream_cli() {
|
|
if std::env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() {
|
|
println!(
|
|
"Skipping test because it cannot execute when network is disabled in a Codex sandbox."
|
|
);
|
|
return;
|
|
}
|
|
|
|
let fixture =
|
|
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/cli_responses_fixture.sse");
|
|
|
|
let home = TempDir::new().unwrap();
|
|
let mut cmd = AssertCommand::new("cargo");
|
|
cmd.arg("run")
|
|
.arg("-p")
|
|
.arg("codex-cli")
|
|
.arg("--quiet")
|
|
.arg("--")
|
|
.arg("exec")
|
|
.arg("--skip-git-repo-check")
|
|
.arg("-C")
|
|
.arg(env!("CARGO_MANIFEST_DIR"))
|
|
.arg("hello?");
|
|
cmd.env("CODEX_HOME", home.path())
|
|
.env("OPENAI_API_KEY", "dummy")
|
|
.env("CODEX_RS_SSE_FIXTURE", fixture)
|
|
.env("OPENAI_BASE_URL", "http://unused.local");
|
|
|
|
let output = cmd.output().unwrap();
|
|
assert!(output.status.success());
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
assert!(stdout.contains("fixture hello"));
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn integration_creates_and_checks_session_file() {
|
|
// Honor sandbox network restrictions for CI parity with the other tests.
|
|
if std::env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() {
|
|
println!(
|
|
"Skipping test because it cannot execute when network is disabled in a Codex sandbox."
|
|
);
|
|
return;
|
|
}
|
|
|
|
// 1. Temp home so we read/write isolated session files.
|
|
let home = TempDir::new().unwrap();
|
|
|
|
// 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. Use the same offline SSE fixture as responses_api_stream_cli so the test is hermetic.
|
|
let fixture =
|
|
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/cli_responses_fixture.sse");
|
|
|
|
// 4. Run the codex CLI through cargo (ensures the right bin is built) and invoke `exec`,
|
|
// which is what records a session.
|
|
let mut cmd = AssertCommand::new("cargo");
|
|
cmd.arg("run")
|
|
.arg("-p")
|
|
.arg("codex-cli")
|
|
.arg("--quiet")
|
|
.arg("--")
|
|
.arg("exec")
|
|
.arg("--skip-git-repo-check")
|
|
.arg("-C")
|
|
.arg(env!("CARGO_MANIFEST_DIR"))
|
|
.arg(&prompt);
|
|
cmd.env("CODEX_HOME", home.path())
|
|
.env("OPENAI_API_KEY", "dummy")
|
|
.env("CODEX_RS_SSE_FIXTURE", &fixture)
|
|
// Required for CLI arg parsing even though fixture short-circuits network usage.
|
|
.env("OPENAI_BASE_URL", "http://unused.local");
|
|
|
|
let output = cmd.output().unwrap();
|
|
assert!(
|
|
output.status.success(),
|
|
"codex-cli exec failed: {}",
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
|
|
// 5. Sessions are written asynchronously; wait briefly for the directory to appear.
|
|
let sessions_dir = home.path().join("sessions");
|
|
let start = Instant::now();
|
|
while !sessions_dir.exists() && start.elapsed() < Duration::from_secs(2) {
|
|
std::thread::sleep(Duration::from_millis(50));
|
|
}
|
|
|
|
// 6. Scan all session files and find the one that contains our marker.
|
|
let mut matching_files = vec![];
|
|
for entry in WalkDir::new(&sessions_dir) {
|
|
let entry = entry.unwrap();
|
|
if entry.file_type().is_file() && entry.file_name().to_string_lossy().ends_with(".jsonl") {
|
|
let path = entry.path();
|
|
let content = std::fs::read_to_string(path).unwrap();
|
|
let mut lines = content.lines();
|
|
// Skip SessionMeta (first line)
|
|
let _ = lines.next();
|
|
for line in lines {
|
|
let item: Value = serde_json::from_str(line).unwrap();
|
|
if let Some("message") = item.get("type").and_then(|t| t.as_str()) {
|
|
if let Some(content) = item.get("content") {
|
|
if content.to_string().contains(&marker) {
|
|
matching_files.push(path.to_owned());
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
assert_eq!(
|
|
matching_files.len(),
|
|
1,
|
|
"Expected exactly one session file containing the marker, found {}",
|
|
matching_files.len()
|
|
);
|
|
let path = &matching_files[0];
|
|
|
|
// 7. Verify directory structure: sessions/YYYY/MM/DD/filename.jsonl
|
|
let rel = match path.strip_prefix(&sessions_dir) {
|
|
Ok(r) => r,
|
|
Err(_) => panic!("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}"
|
|
);
|
|
// Range checks (best-effort; won't fail on leading zeros)
|
|
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}");
|
|
}
|
|
|
|
// 8. Parse SessionMeta line and basic sanity checks.
|
|
let content = std::fs::read_to_string(path).unwrap();
|
|
let mut lines = content.lines();
|
|
let meta: Value = serde_json::from_str(lines.next().unwrap()).unwrap();
|
|
assert!(meta.get("id").is_some(), "SessionMeta missing id");
|
|
assert!(
|
|
meta.get("timestamp").is_some(),
|
|
"SessionMeta missing timestamp"
|
|
);
|
|
|
|
// 9. Confirm at least one message contains the marker.
|
|
let mut found_message = false;
|
|
for line in lines {
|
|
let item: Value = serde_json::from_str(line).unwrap();
|
|
if item.get("type").map(|t| t == "message").unwrap_or(false) {
|
|
if let Some(content) = item.get("content") {
|
|
if content.to_string().contains(&marker) {
|
|
found_message = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
assert!(
|
|
found_message,
|
|
"No message found in session file containing the marker"
|
|
);
|
|
}
|