mirror of
https://github.com/tiennm99/claude-code-usage-bubble.git
synced 2026-10-23 16:16:39 +00:00
Merge branch 'worktree-wf_093aad93-ad0-2' into review-fixes
This commit is contained in:
commit
995173c48f
4 files changed
+479
-40
No files matched your search
@@ -106,6 +106,38 @@ mod tests {
|
||||
assert_eq!(token.expires_at_unix_ms, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signature_changes_when_a_rewrite_changes_the_file_size() {
|
||||
use crate::creds::CredentialSource;
|
||||
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"ccub-local-creds-{}-{}.json",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos())
|
||||
.unwrap_or_default()
|
||||
));
|
||||
let creds = LocalClaudeCreds {
|
||||
id: format!("local:{}", path.display()),
|
||||
path: path.clone(),
|
||||
};
|
||||
assert_eq!(creds.signature(), None, "missing file has no signature");
|
||||
|
||||
std::fs::write(&path, r#"{"claudeAiOauth":{"accessToken":"a"}}"#).expect("write");
|
||||
let before = creds.signature().expect("file exists");
|
||||
assert_eq!(
|
||||
Some(before.clone()),
|
||||
creds.signature(),
|
||||
"stable while unchanged"
|
||||
);
|
||||
|
||||
std::fs::write(&path, r#"{"claudeAiOauth":{"accessToken":"rotated"}}"#).expect("rewrite");
|
||||
let after = creds.signature().expect("file exists");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
assert_ne!(before, after);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_fields_are_reported() {
|
||||
let err = parse_claude_json(&fixture("claude-credentials-missing-token.json")).unwrap_err();
|
||||
|
||||
+151
-9
@@ -44,29 +44,38 @@ impl UsageProvider for ClaudeProvider {
|
||||
if token_is_expired(token.expires_at_unix_ms) {
|
||||
return Err(Error::AuthRequired);
|
||||
}
|
||||
fetch_with_fallback(http, &token.access_token)
|
||||
fetch_with_fallback(http, &token.access_token, USAGE_URL, MESSAGES_URL)
|
||||
}
|
||||
}
|
||||
|
||||
fn fetch_with_fallback(http: &Client, token: &str) -> Result<UsageWindows, Error> {
|
||||
fn fetch_with_fallback(
|
||||
http: &Client,
|
||||
token: &str,
|
||||
usage_url: &str,
|
||||
messages_url: &str,
|
||||
) -> Result<UsageWindows, Error> {
|
||||
// First try the dedicated endpoint.
|
||||
match try_usage_endpoint(http, token)? {
|
||||
match try_usage_endpoint(http, token, usage_url)? {
|
||||
Some(windows) if has_reset_times(&windows) => return Ok(windows),
|
||||
Some(partial) => {
|
||||
// Got percentages but no reset times — fill them in from messages.
|
||||
if let Ok(fallback) = try_messages_endpoint(http, token) {
|
||||
if let Ok(fallback) = try_messages_endpoint(http, token, messages_url) {
|
||||
return Ok(merge_resets(partial, fallback));
|
||||
}
|
||||
return Ok(partial);
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
try_messages_endpoint(http, token)
|
||||
try_messages_endpoint(http, token, messages_url)
|
||||
}
|
||||
|
||||
fn try_usage_endpoint(http: &Client, token: &str) -> Result<Option<UsageWindows>, Error> {
|
||||
fn try_usage_endpoint(
|
||||
http: &Client,
|
||||
token: &str,
|
||||
url: &str,
|
||||
) -> Result<Option<UsageWindows>, Error> {
|
||||
let resp = match http
|
||||
.get(USAGE_URL)
|
||||
.get(url)
|
||||
.header("Authorization", &format!("Bearer {token}"))
|
||||
.header("anthropic-beta", BETA_HEADER)
|
||||
.send()
|
||||
@@ -95,7 +104,7 @@ fn parse_usage_body(body: &[u8]) -> Option<UsageWindows> {
|
||||
Some(UsageWindows { primary, secondary })
|
||||
}
|
||||
|
||||
fn try_messages_endpoint(http: &Client, token: &str) -> Result<UsageWindows, Error> {
|
||||
fn try_messages_endpoint(http: &Client, token: &str, url: &str) -> Result<UsageWindows, Error> {
|
||||
for model in PROBE_MODELS {
|
||||
let body = serde_json::json!({
|
||||
"model": model,
|
||||
@@ -103,7 +112,7 @@ fn try_messages_endpoint(http: &Client, token: &str) -> Result<UsageWindows, Err
|
||||
"messages": [{"role": "user", "content": "."}],
|
||||
});
|
||||
let resp = match http
|
||||
.post(MESSAGES_URL)
|
||||
.post(url)
|
||||
.header("Authorization", &format!("Bearer {token}"))
|
||||
.header("anthropic-version", API_VERSION)
|
||||
.header("anthropic-beta", BETA_HEADER)
|
||||
@@ -264,6 +273,8 @@ struct Bucket {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::net::test_server::{serve_once, Served};
|
||||
use crate::net::USER_AGENT;
|
||||
|
||||
fn at(secs: u64) -> Option<SystemTime> {
|
||||
Some(UNIX_EPOCH + Duration::from_secs(secs))
|
||||
@@ -381,4 +392,135 @@ mod tests {
|
||||
assert!(token_is_expired(Some(0)));
|
||||
assert!(!token_is_expired(Some(i64::MAX)));
|
||||
}
|
||||
|
||||
// --- HTTP paths against a loopback server ---
|
||||
|
||||
const RATE_LIMIT_HEADERS: &[(&str, &str)] = &[
|
||||
("anthropic-ratelimit-unified-5h-utilization", "0.25"),
|
||||
("anthropic-ratelimit-unified-5h-reset", "1000"),
|
||||
("anthropic-ratelimit-unified-7d-utilization", "0.5"),
|
||||
("anthropic-ratelimit-unified-7d-reset", "2000"),
|
||||
];
|
||||
|
||||
fn client() -> Client {
|
||||
Client::without_proxy(USER_AGENT)
|
||||
}
|
||||
|
||||
fn fetch(usage: &Served, messages: &Served) -> Result<UsageWindows, Error> {
|
||||
fetch_with_fallback(&client(), "tok", &usage.base, &messages.base)
|
||||
}
|
||||
|
||||
fn received(served: &Served) -> String {
|
||||
served
|
||||
.request
|
||||
.recv_timeout(Duration::from_secs(10))
|
||||
.expect("server saw the request")
|
||||
}
|
||||
|
||||
fn assert_not_called(served: &Served) {
|
||||
assert!(
|
||||
served
|
||||
.request
|
||||
.recv_timeout(Duration::from_millis(300))
|
||||
.is_err(),
|
||||
"endpoint must not be called"
|
||||
);
|
||||
}
|
||||
|
||||
fn messages_with_headers() -> Served {
|
||||
serve_once("200 OK", RATE_LIMIT_HEADERS, b"{}".to_vec())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_auth_failures_stop_before_the_messages_fallback() {
|
||||
for status in ["401 Unauthorized", "403 Forbidden"] {
|
||||
let usage = serve_once(status, &[], b"{}".to_vec());
|
||||
let messages = messages_with_headers();
|
||||
let result = fetch(&usage, &messages);
|
||||
assert!(
|
||||
matches!(result, Err(Error::AuthRequired)),
|
||||
"{status}: {result:?}"
|
||||
);
|
||||
let request = received(&usage);
|
||||
assert!(request.starts_with("GET / HTTP/1.1\r\n"), "{request}");
|
||||
let lower = request.to_ascii_lowercase();
|
||||
assert!(lower.contains("authorization: bearer tok\r\n"), "{request}");
|
||||
assert!(
|
||||
lower.contains("anthropic-beta: oauth-2025-04-20\r\n"),
|
||||
"{request}"
|
||||
);
|
||||
assert_not_called(&messages);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_server_error_falls_back_to_messages_headers() {
|
||||
let usage = serve_once("500 Internal Server Error", &[], b"oops".to_vec());
|
||||
let messages = messages_with_headers();
|
||||
let w = fetch(&usage, &messages).expect("fallback windows");
|
||||
assert_eq!(w.primary.utilization, 25.0);
|
||||
assert_eq!(w.primary.resets_at, at(1000));
|
||||
assert_eq!(w.secondary.utilization, 50.0);
|
||||
assert_eq!(w.secondary.resets_at, at(2000));
|
||||
let request = received(&messages);
|
||||
assert!(request.starts_with("POST / HTTP/1.1\r\n"), "{request}");
|
||||
assert!(request
|
||||
.to_ascii_lowercase()
|
||||
.contains("anthropic-version: 2023-06-01\r\n"));
|
||||
assert!(request.contains("\"max_tokens\":1"), "{request}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unparsable_usage_body_falls_back_to_messages_headers() {
|
||||
let usage = serve_once("200 OK", &[], b"not json".to_vec());
|
||||
let messages = messages_with_headers();
|
||||
let w = fetch(&usage, &messages).expect("fallback windows");
|
||||
assert_eq!(w.primary.utilization, 25.0);
|
||||
assert_eq!(w.secondary.resets_at, at(2000));
|
||||
received(&messages);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_usage_body_skips_the_messages_call() {
|
||||
let usage = serve_once("200 OK", &[], fixture("anthropic-usage-full.json"));
|
||||
let messages = messages_with_headers();
|
||||
let w = fetch(&usage, &messages).expect("usage windows");
|
||||
assert_eq!(w.primary.utilization, 42.5);
|
||||
assert_eq!(w.primary.resets_at, at(1_761_040_800));
|
||||
assert_not_called(&messages);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_usage_body_merges_reset_times_from_messages() {
|
||||
let usage = serve_once("200 OK", &[], fixture("anthropic-usage-partial.json"));
|
||||
let messages = messages_with_headers();
|
||||
let w = fetch(&usage, &messages).expect("merged windows");
|
||||
assert_eq!(w.primary.utilization, 7.0, "usage percent is kept");
|
||||
assert_eq!(w.primary.resets_at, at(1000), "missing reset is filled in");
|
||||
assert_eq!(w.secondary.utilization, 61.0);
|
||||
assert_eq!(
|
||||
w.secondary.resets_at,
|
||||
at(1_709_251_199),
|
||||
"known reset is kept"
|
||||
);
|
||||
received(&messages);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn messages_auth_failure_is_auth_required() {
|
||||
let usage = serve_once("503 Service Unavailable", &[], Vec::new());
|
||||
let messages = serve_once("401 Unauthorized", RATE_LIMIT_HEADERS, b"{}".to_vec());
|
||||
let result = fetch(&usage, &messages);
|
||||
assert!(matches!(result, Err(Error::AuthRequired)), "{result:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn messages_without_rate_limit_headers_is_a_bad_response() {
|
||||
// The second probe model finds the one-shot server gone, which
|
||||
// counts as a failed probe too.
|
||||
let usage = serve_once("500 Internal Server Error", &[], Vec::new());
|
||||
let messages = serve_once("200 OK", &[], b"{}".to_vec());
|
||||
let result = fetch(&usage, &messages);
|
||||
assert!(matches!(result, Err(Error::BadResponse(_))), "{result:?}");
|
||||
}
|
||||
}
|
||||
+117
-26
@@ -35,35 +35,50 @@ impl UsageProvider for ChatGptProvider {
|
||||
fn poll(&mut self, http: &Client) -> Result<UsageWindows, Error> {
|
||||
let source = self.locator.first_available().ok_or(Error::NoCredentials)?;
|
||||
let token = source.read()?;
|
||||
let mut req = http
|
||||
.get(USAGE_URL)
|
||||
.header("Authorization", &format!("Bearer {}", token.access_token))
|
||||
.header("User-Agent", "codex-cli");
|
||||
if let Some(account_id) = token.account_id.as_deref().filter(|s| !s.is_empty()) {
|
||||
req = req.header("ChatGPT-Account-Id", account_id);
|
||||
}
|
||||
let resp = match req.send() {
|
||||
Ok(r) => r,
|
||||
Err(crate::net::Error::Status(code)) if code == 401 || code == 403 => {
|
||||
return Err(Error::AuthRequired);
|
||||
}
|
||||
Err(e) => return Err(Error::Network(e)),
|
||||
};
|
||||
if resp.status() == 401 || resp.status() == 403 {
|
||||
fetch(
|
||||
http,
|
||||
USAGE_URL,
|
||||
&token.access_token,
|
||||
token.account_id.as_deref(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Usage windows from the Codex usage endpoint at `url`. The account header
|
||||
/// is sent only when `account_id` is set and not empty.
|
||||
fn fetch(
|
||||
http: &Client,
|
||||
url: &str,
|
||||
token: &str,
|
||||
account_id: Option<&str>,
|
||||
) -> Result<UsageWindows, Error> {
|
||||
let mut req = http
|
||||
.get(url)
|
||||
.header("Authorization", &format!("Bearer {token}"))
|
||||
.header("User-Agent", "codex-cli");
|
||||
if let Some(account_id) = account_id.filter(|s| !s.is_empty()) {
|
||||
req = req.header("ChatGPT-Account-Id", account_id);
|
||||
}
|
||||
let resp = match req.send() {
|
||||
Ok(r) => r,
|
||||
Err(crate::net::Error::Status(code)) if code == 401 || code == 403 => {
|
||||
return Err(Error::AuthRequired);
|
||||
}
|
||||
if !(200..300).contains(&resp.status()) {
|
||||
return Err(Error::BadResponse(format!(
|
||||
"Codex usage endpoint returned {}",
|
||||
resp.status()
|
||||
)));
|
||||
}
|
||||
let body: Envelope = resp
|
||||
.json()
|
||||
.map_err(|e| Error::BadResponse(format!("JSON parse: {e}")))?;
|
||||
envelope_to_windows(body)
|
||||
.ok_or_else(|| Error::BadResponse("missing rate_limit section".into()))
|
||||
Err(e) => return Err(Error::Network(e)),
|
||||
};
|
||||
if resp.status() == 401 || resp.status() == 403 {
|
||||
return Err(Error::AuthRequired);
|
||||
}
|
||||
if !(200..300).contains(&resp.status()) {
|
||||
return Err(Error::BadResponse(format!(
|
||||
"Codex usage endpoint returned {}",
|
||||
resp.status()
|
||||
)));
|
||||
}
|
||||
let body: Envelope = resp
|
||||
.json()
|
||||
.map_err(|e| Error::BadResponse(format!("JSON parse: {e}")))?;
|
||||
envelope_to_windows(body).ok_or_else(|| Error::BadResponse("missing rate_limit section".into()))
|
||||
}
|
||||
|
||||
fn envelope_to_windows(envelope: Envelope) -> Option<UsageWindows> {
|
||||
@@ -129,6 +144,8 @@ impl<T> FlattenBoxed<T> for Option<Option<Box<T>>> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::net::test_server::{serve_once, Served};
|
||||
use crate::net::USER_AGENT;
|
||||
|
||||
fn at(secs: u64) -> Option<SystemTime> {
|
||||
Some(UNIX_EPOCH + Duration::from_secs(secs))
|
||||
@@ -173,4 +190,78 @@ mod tests {
|
||||
assert_eq!(w.primary.resets_at, None);
|
||||
assert_eq!(w.secondary.resets_at, at(0));
|
||||
}
|
||||
|
||||
// --- HTTP path against a loopback server ---
|
||||
|
||||
fn fixture(name: &str) -> Vec<u8> {
|
||||
let path = format!("{}/tests/fixtures/{name}", env!("CARGO_MANIFEST_DIR"));
|
||||
std::fs::read(&path).unwrap_or_else(|e| panic!("read {path}: {e}"))
|
||||
}
|
||||
|
||||
fn fetch_from(served: &Served, account_id: Option<&str>) -> Result<UsageWindows, Error> {
|
||||
fetch(
|
||||
&Client::without_proxy(USER_AGENT),
|
||||
&served.base,
|
||||
"tok",
|
||||
account_id,
|
||||
)
|
||||
}
|
||||
|
||||
fn received(served: &Served) -> String {
|
||||
served
|
||||
.request
|
||||
.recv_timeout(Duration::from_secs(10))
|
||||
.expect("server saw the request")
|
||||
.to_ascii_lowercase()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn success_maps_windows_and_sends_codex_headers() {
|
||||
let served = serve_once("200 OK", &[], fixture("chatgpt-usage-full.json"));
|
||||
let w = fetch_from(&served, None).expect("windows");
|
||||
assert_eq!(w.primary.utilization, 12.0);
|
||||
assert_eq!(w.secondary.resets_at, at(1_761_508_800));
|
||||
let request = received(&served);
|
||||
assert!(
|
||||
request.contains("authorization: bearer tok\r\n"),
|
||||
"{request}"
|
||||
);
|
||||
assert!(request.contains("user-agent: codex-cli\r\n"), "{request}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unauthorized_is_auth_required() {
|
||||
let served = serve_once("401 Unauthorized", &[], b"{}".to_vec());
|
||||
let result = fetch_from(&served, None);
|
||||
assert!(matches!(result, Err(Error::AuthRequired)), "{result:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_error_is_a_bad_response() {
|
||||
let served = serve_once("500 Internal Server Error", &[], b"{}".to_vec());
|
||||
let result = fetch_from(&served, None);
|
||||
assert!(matches!(result, Err(Error::BadResponse(_))), "{result:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_header_is_sent_only_with_an_account_id() {
|
||||
let body = || fixture("chatgpt-usage-full.json");
|
||||
let with_id = serve_once("200 OK", &[], body());
|
||||
fetch_from(&with_id, Some("acct-123")).expect("windows");
|
||||
let request = received(&with_id);
|
||||
assert!(
|
||||
request.contains("chatgpt-account-id: acct-123\r\n"),
|
||||
"{request}"
|
||||
);
|
||||
|
||||
for account_id in [None, Some("")] {
|
||||
let served = serve_once("200 OK", &[], body());
|
||||
fetch_from(&served, account_id).expect("windows");
|
||||
let request = received(&served);
|
||||
assert!(
|
||||
!request.contains("chatgpt-account-id"),
|
||||
"{account_id:?}: {request}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+179
-5
@@ -20,6 +20,17 @@ const EXTRA_HOME_DIRS: &[&str] = &[".local/bin", ".claude/local"];
|
||||
#[cfg(unix)]
|
||||
const EXTRA_SYSTEM_DIRS: &[&str] = &["/opt/homebrew/bin", "/usr/local/bin"];
|
||||
|
||||
/// `codex exec` refuses to run outside a trusted directory or repository,
|
||||
/// and the refresh runs from the temp directory, so that check is skipped.
|
||||
/// The read-only sandbox keeps the one-shot prompt from changing any file.
|
||||
const CODEX_ARGS: &[&str] = &[
|
||||
"exec",
|
||||
"--skip-git-repo-check",
|
||||
"--sandbox",
|
||||
"read-only",
|
||||
".",
|
||||
];
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Outcome {
|
||||
Refreshed,
|
||||
@@ -42,9 +53,19 @@ impl Orchestrator {
|
||||
}
|
||||
|
||||
pub fn refresh(&self, source: &dyn CredentialSource) -> Outcome {
|
||||
self.refresh_with(source, spawn_cli)
|
||||
}
|
||||
|
||||
/// Spawn the refresh CLI through `spawn`, then wait for the credential
|
||||
/// signature to change. `spawn` returns `false` when no CLI could start.
|
||||
fn refresh_with(
|
||||
&self,
|
||||
source: &dyn CredentialSource,
|
||||
spawn: impl FnOnce(&RefreshHint) -> bool,
|
||||
) -> Outcome {
|
||||
let initial_sig = source.signature();
|
||||
let hint = source.refresh_hint();
|
||||
if !spawn_cli(&hint) {
|
||||
if !spawn(&hint) {
|
||||
return Outcome::CliMissing;
|
||||
}
|
||||
let start = Instant::now();
|
||||
@@ -73,7 +94,7 @@ fn cli_for(hint: &RefreshHint) -> (&'static [&'static str], &'static [&'static s
|
||||
RefreshHint::LocalClaudeCli => (&["claude.cmd", "claude.exe", "claude"], &["-p", "."]),
|
||||
RefreshHint::LocalCodexCli => (
|
||||
&["codex.cmd", "codex.ps1", "codex.exe", "codex"],
|
||||
&["exec", "."],
|
||||
CODEX_ARGS,
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -82,18 +103,22 @@ fn cli_for(hint: &RefreshHint) -> (&'static [&'static str], &'static [&'static s
|
||||
fn cli_for(hint: &RefreshHint) -> (&'static [&'static str], &'static [&'static str]) {
|
||||
match hint {
|
||||
RefreshHint::LocalClaudeCli => (&["claude"], &["-p", "."]),
|
||||
RefreshHint::LocalCodexCli => (&["codex"], &["exec", "."]),
|
||||
RefreshHint::LocalCodexCli => (&["codex"], CODEX_ARGS),
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn the first candidate that starts. The child is detached: a reaper
|
||||
/// thread waits on it so no zombie is left behind.
|
||||
/// thread waits on it so no zombie is left behind. The child runs from the
|
||||
/// temp directory rather than the app's own working directory (the home
|
||||
/// folder, `/` or System32), so the CLI never runs inside a project or
|
||||
/// loads its instructions and hooks.
|
||||
fn spawn_first(candidates: &[&str], args: &[&str]) -> bool {
|
||||
for name in candidates {
|
||||
let Some(mut cmd) = command_for(name, args) else {
|
||||
continue;
|
||||
};
|
||||
cmd.env_remove("CLAUDECODE")
|
||||
cmd.current_dir(std::env::temp_dir())
|
||||
.env_remove("CLAUDECODE")
|
||||
.env_remove("CLAUDE_CODE_ENTRYPOINT")
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
@@ -209,6 +234,129 @@ fn find_executable(name: &str, dirs: &[std::path::PathBuf]) -> Option<std::path:
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod orchestrator_tests {
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::*;
|
||||
use crate::creds::{Error as CredsError, Locator, Token};
|
||||
|
||||
/// A source whose signature the test sets through a shared version
|
||||
/// number; version zero reports no signature, like a missing file.
|
||||
struct FakeSource {
|
||||
id: String,
|
||||
version: Arc<AtomicU32>,
|
||||
}
|
||||
|
||||
fn fake(id: &str, version: u32) -> (FakeSource, Arc<AtomicU32>) {
|
||||
let version = Arc::new(AtomicU32::new(version));
|
||||
let source = FakeSource {
|
||||
id: id.to_string(),
|
||||
version: Arc::clone(&version),
|
||||
};
|
||||
(source, version)
|
||||
}
|
||||
|
||||
impl CredentialSource for FakeSource {
|
||||
fn id(&self) -> &str {
|
||||
&self.id
|
||||
}
|
||||
|
||||
fn read(&self) -> Result<Token, CredsError> {
|
||||
Err(CredsError::Unavailable)
|
||||
}
|
||||
|
||||
fn signature(&self) -> Option<String> {
|
||||
match self.version.load(Ordering::SeqCst) {
|
||||
0 => None,
|
||||
v => Some(format!("{}|{v}", self.id)),
|
||||
}
|
||||
}
|
||||
|
||||
fn refresh_hint(&self) -> RefreshHint {
|
||||
RefreshHint::LocalCodexCli
|
||||
}
|
||||
}
|
||||
|
||||
fn fast_orchestrator() -> Orchestrator {
|
||||
Orchestrator {
|
||||
timeout: Duration::from_millis(30),
|
||||
poll_interval: Duration::from_millis(2),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_refresh_skips_the_repository_check_in_a_read_only_sandbox() {
|
||||
let (_, args) = cli_for(&RefreshHint::LocalCodexCli);
|
||||
assert_eq!(args.first(), Some(&"exec"));
|
||||
assert!(args.contains(&"--skip-git-repo-check"), "{args:?}");
|
||||
let sandbox = args
|
||||
.iter()
|
||||
.position(|a| *a == "--sandbox")
|
||||
.expect("sandbox flag");
|
||||
assert_eq!(args.get(sandbox + 1), Some(&"read-only"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signature_change_after_spawn_is_refreshed() {
|
||||
let (source, version) = fake("fake", 1);
|
||||
let outcome = fast_orchestrator().refresh_with(&source, |hint| {
|
||||
assert!(matches!(hint, RefreshHint::LocalCodexCli));
|
||||
version.store(2, Ordering::SeqCst);
|
||||
true
|
||||
});
|
||||
assert_eq!(outcome, Outcome::Refreshed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchanged_signature_times_out() {
|
||||
let (source, _) = fake("fake", 1);
|
||||
let outcome = fast_orchestrator().refresh_with(&source, |_| true);
|
||||
assert_eq!(outcome, Outcome::Timeout);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_spawn_is_cli_missing_without_waiting() {
|
||||
let (source, _) = fake("fake", 1);
|
||||
let orchestrator = Orchestrator {
|
||||
timeout: Duration::from_secs(60),
|
||||
poll_interval: Duration::from_secs(60),
|
||||
};
|
||||
let start = Instant::now();
|
||||
assert_eq!(
|
||||
orchestrator.refresh_with(&source, |_| false),
|
||||
Outcome::CliMissing
|
||||
);
|
||||
assert!(start.elapsed() < Duration::from_secs(5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_available_skips_missing_sources_and_keeps_order() {
|
||||
let (missing, _) = fake("missing", 0);
|
||||
let (second, _) = fake("second", 1);
|
||||
let (third, _) = fake("third", 1);
|
||||
let locator = Locator::new(vec![Box::new(missing), Box::new(second), Box::new(third)]);
|
||||
assert_eq!(locator.first_available().map(|s| s.id()), Some("second"));
|
||||
|
||||
let (only_missing, _) = fake("missing", 0);
|
||||
assert!(Locator::new(vec![Box::new(only_missing)])
|
||||
.first_available()
|
||||
.is_none());
|
||||
assert!(Locator::new(Vec::new()).first_available().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_available_prefers_an_earlier_source_once_it_appears() {
|
||||
let (first, first_version) = fake("first", 0);
|
||||
let (second, _) = fake("second", 1);
|
||||
let locator = Locator::new(vec![Box::new(first), Box::new(second)]);
|
||||
assert_eq!(locator.first_available().map(|s| s.id()), Some("second"));
|
||||
first_version.store(1, Ordering::SeqCst);
|
||||
assert_eq!(locator.first_available().map(|s| s.id()), Some("first"));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod tests {
|
||||
use std::ffi::OsString;
|
||||
@@ -308,6 +456,32 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spawned_child_runs_in_the_temp_directory() {
|
||||
let dir = temp_dir("cwd");
|
||||
let script = dir.join("fake-cli");
|
||||
let marker = dir.join("cwd");
|
||||
std::fs::write(
|
||||
&script,
|
||||
format!("#!/bin/sh\npwd -P > '{}'\n", marker.display()),
|
||||
)
|
||||
.expect("write script");
|
||||
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).expect("chmod");
|
||||
assert!(spawn_first(&[script.to_str().expect("utf8 path")], &[]));
|
||||
// The file exists before `pwd` writes, so wait for its full line.
|
||||
let read = || std::fs::read_to_string(&marker).unwrap_or_default();
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
while !read().ends_with('\n') && Instant::now() < deadline {
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
let written = read();
|
||||
let expected = std::env::temp_dir()
|
||||
.canonicalize()
|
||||
.expect("temp dir resolves");
|
||||
assert_eq!(PathBuf::from(written.trim()), expected);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn child_path_resolves_env_interpreter_from_extra_dirs() {
|
||||
let tools = temp_dir("tools");
|
||||
|
||||
Reference in new issue
Block a user