mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-10-21 05:23:19 +00:00
This commit is contained in:
parent
82469eab68
commit
ab73bd33b4
4 changed files with 324 additions and 13 deletions
|
|
@ -7,6 +7,7 @@
|
|||
|
||||
### Fixed
|
||||
- **Cursor Enterprise and team seats connect in Plan & Usage instead of showing an unrecognized quota response.** Their `usage-summary` carries no individual meter, only a team on-demand meter, which now fills the bar under the Enterprise plan label. On other plans it is listed beside the monthly window. In the macOS app a rate limit, outage or unrecognized response no longer adds sign-in guidance, which is only shown when the session is missing or rejected. Reported in #1546, thanks @paulodearaujo.
|
||||
- **Windows: the Claude quota shows for Claude Code installed only inside WSL.** The tray's Plan panel and `codeburn quota` read the credential from the Windows profile only, so a WSL-only login showed nothing. Both now also read `~/.claude/.credentials.json` in each running distro's home over `\\wsl$`, never waking a stopped distro and never writing to it; when both sides are logged in, the credential that expires last wins. A slow or unreachable share times out instead of holding up the poll. `CODEBURN_WSL=off` skips it. (#1061)
|
||||
- **OpenCode: Vertex turns and turns CodeBurn cannot price no longer show $0.** OpenCode 2 records a Vertex model as `{"id":"claude-sonnet-5@default","providerID":"google-vertex-anthropic"}`. Per-message turns already price on the bare id; the session-level rollup still used `provider/model` and now uses the bare id too. Turns through `google-vertex` and `google-vertex-anthropic` land on their own row, like "Sonnet 5 (Vertex)". A turn whose model CodeBurn cannot price used the cost OpenCode recorded only until it reached the session cache, which re-priced it from tokens to $0 on every read; that recorded cost now survives the cache, and a price CodeBurn learns later still wins. Applies to Kilo Code too. OpenCode and Kilo Code sessions re-parse once and settled days re-derive. Reported in #1547, thanks @bulwahn.
|
||||
- **Cursor Agent counts every assistant message instead of one turn per user message.** Agentic loops emit dozens of assistant messages per user message and the parser kept only the first (on one real corpus: 247 of 4,946 messages, ~4% of assistant text). Each message now carries the last user message forward, tool_use inputs join the output text, and input tokens use the full user text, billed once per user message, while the display stays truncated at 500 chars. The parse version bump re-parses cached sessions, and the daily cache moves to v36 so settled days re-derive.
|
||||
- **Windows Store installs: no update offer the Store cannot deliver, and `codeburn menubar` opens the Store copy.** The desktop app read GitHub releases for its update banner, so a Store copy was told about a version still waiting on Store certification and sent to a Store page that did not have it. Store installs now skip the GitHub check and the About update button, like the tray app already did; the Store updates them. `codeburn menubar` spawned the tray exe inside `WindowsApps`, which Windows refuses from outside the package (`spawn EPERM`); it now opens the Store app by its app id, and the desktop app starts the tray. Reported in #1520, thanks @Brandin.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
import { wslHomes } from '../wsl.js'
|
||||
import { fraction, quotaRequestSignal, readKeychainPassword, readSecureFile, sanitizeError } from './security.js'
|
||||
import type { KeychainOutcome } from './security.js'
|
||||
import type { QuotaProvider, QuotaWindow } from './types.js'
|
||||
|
|
@ -15,6 +16,8 @@ type CredentialSource = 'file' | 'keychain'
|
|||
export type ClaudeDeps = {
|
||||
fetch: typeof fetch
|
||||
credentialPath: string
|
||||
/** Credential files inside running WSL distros (#1061); empty off Windows. */
|
||||
wslCredentialPaths: () => string[]
|
||||
readFile: typeof readSecureFile
|
||||
keychain?: () => Promise<KeychainOutcome>
|
||||
now: () => number
|
||||
|
|
@ -23,6 +26,7 @@ export type ClaudeDeps = {
|
|||
const defaults: ClaudeDeps = {
|
||||
fetch: globalThis.fetch,
|
||||
credentialPath: path.join(os.homedir(), '.claude', '.credentials.json'),
|
||||
wslCredentialPaths: () => wslHomes().map(home => `${home}\\.claude\\.credentials.json`),
|
||||
readFile: readSecureFile,
|
||||
now: Date.now,
|
||||
}
|
||||
|
|
@ -43,11 +47,42 @@ function parseCredential(raw: string): ClaudeCredential | null {
|
|||
}
|
||||
}
|
||||
|
||||
async function credentialFromFile(deps: ClaudeDeps): Promise<ClaudeCredential | null> {
|
||||
const raw = await deps.readFile(deps.credentialPath, 64 * 1024)
|
||||
async function credentialAt(file: string, deps: ClaudeDeps): Promise<ClaudeCredential | null> {
|
||||
const raw = await deps.readFile(file, 64 * 1024)
|
||||
return raw ? parseCredential(raw) : null
|
||||
}
|
||||
|
||||
// A 9P share whose distro is going down can stall a read; the quota poll must
|
||||
// never wait on it. The abandoned read settles (or not) on its own.
|
||||
const WSL_READ_TIMEOUT_MS = 2000
|
||||
|
||||
function wslCredentialAt(file: string, deps: ClaudeDeps): Promise<ClaudeCredential | null> {
|
||||
let timer: NodeJS.Timeout | undefined
|
||||
const timeout = new Promise<null>(resolve => { timer = setTimeout(resolve, WSL_READ_TIMEOUT_MS, null) })
|
||||
return Promise.race([credentialAt(file, deps).catch(() => null), timeout]).finally(() => clearTimeout(timer))
|
||||
}
|
||||
|
||||
/**
|
||||
* The Windows-home credential plus one per running WSL distro home. A user can
|
||||
* be logged in on both sides; the one expiring last is the one Claude Code
|
||||
* renewed most recently. A broken WSL file is skipped; a broken Windows file
|
||||
* still surfaces its error when nothing else is usable.
|
||||
*/
|
||||
async function credentialFromFiles(deps: ClaudeDeps): Promise<{ credential: ClaudeCredential; path: string } | null> {
|
||||
let nativeError: unknown
|
||||
const native = credentialAt(deps.credentialPath, deps).catch(error => { nativeError = error; return null })
|
||||
const paths = [deps.credentialPath, ...deps.wslCredentialPaths()]
|
||||
const found = await Promise.all([native, ...paths.slice(1).map(file => wslCredentialAt(file, deps))])
|
||||
let best: { credential: ClaudeCredential; path: string } | null = null
|
||||
for (const [i, credential] of found.entries()) {
|
||||
if (credential && (!best || (credential.expiresAt ?? -Infinity) > (best.credential.expiresAt ?? -Infinity))) {
|
||||
best = { credential, path: paths[i]! }
|
||||
}
|
||||
}
|
||||
if (!best && nativeError !== undefined) throw nativeError
|
||||
return best
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-read whichever store the credential came from. On macOS the credential
|
||||
* usually lives in the Keychain and no file exists at all, so re-reading the
|
||||
|
|
@ -55,8 +90,8 @@ async function credentialFromFile(deps: ClaudeDeps): Promise<ClaudeCredential |
|
|||
* A denied Keychain on a re-read needs no state of its own: the first read
|
||||
* already decided whether that store is reachable.
|
||||
*/
|
||||
async function credentialFrom(source: CredentialSource, deps: ClaudeDeps): Promise<ClaudeCredential | null> {
|
||||
if (source === 'file') return credentialFromFile(deps)
|
||||
async function credentialFrom(source: CredentialSource, file: string, deps: ClaudeDeps): Promise<ClaudeCredential | null> {
|
||||
if (source === 'file') return file === deps.credentialPath ? credentialAt(file, deps) : wslCredentialAt(file, deps)
|
||||
const outcome = await (deps.keychain ?? readClaudeKeychain)()
|
||||
return outcome.status === 'found' ? parseCredential(outcome.value) : null
|
||||
}
|
||||
|
|
@ -142,7 +177,8 @@ export type ClaudeResult = { quota: QuotaProvider; retryAfterSeconds?: number }
|
|||
export async function fetchClaudeQuota(options: Partial<ClaudeDeps> & { signal?: AbortSignal; allowKeychain?: boolean } = {}): Promise<ClaudeResult> {
|
||||
const deps = { ...defaults, ...options }
|
||||
try {
|
||||
let credential = await credentialFromFile(deps)
|
||||
const fromFile = await credentialFromFiles(deps)
|
||||
let credential = fromFile?.credential ?? null
|
||||
let source: CredentialSource = 'file'
|
||||
if (!credential && options.allowKeychain && process.platform === 'darwin') {
|
||||
const outcome = await (deps.keychain ?? readClaudeKeychain)()
|
||||
|
|
@ -154,7 +190,7 @@ export async function fetchClaudeQuota(options: Partial<ClaudeDeps> & { signal?:
|
|||
|
||||
let response = await request(credential.accessToken, deps, options.signal)
|
||||
if (response.status === 401) {
|
||||
const reread = await credentialFrom(source, deps)
|
||||
const reread = await credentialFrom(source, fromFile?.path ?? deps.credentialPath, deps)
|
||||
if (!reread || reread.accessToken === credential.accessToken) {
|
||||
// Nothing but a fresh login can clear a 401 on a credential whose life is
|
||||
// already over, so it is reported as terminal rather than as the blip its
|
||||
|
|
|
|||
85
tests/quota-claude-wsl.test.ts
Normal file
85
tests/quota-claude-wsl.test.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { fetchClaudeQuota } from '../src/quota/claude.js'
|
||||
import { setWslHomes } from '../src/wsl.js'
|
||||
|
||||
// #1061: a WSL-only Claude Code login keeps its credential on the distro's 9P
|
||||
// share, never under the Windows profile. All paths here are fake; no file is read.
|
||||
const WIN = 'C:\\Users\\alice\\.claude\\.credentials.json'
|
||||
const UBUNTU = '\\\\wsl$\\Ubuntu\\home\\alice'
|
||||
const UBUNTU_CRED = `${UBUNTU}\\.claude\\.credentials.json`
|
||||
const DEBIAN_CRED = '\\\\wsl$\\Debian\\home\\bob\\.claude\\.credentials.json'
|
||||
const usage = JSON.stringify({ seven_day: { utilization: 40, resets_at: '2026-07-19T12:00:00Z' } })
|
||||
const stored = (accessToken: string, expiresAt?: number) => JSON.stringify({ claudeAiOauth: { accessToken, expiresAt } })
|
||||
|
||||
function run(files: Record<string, string | (() => Promise<string | null>)>, wslPaths: string[]) {
|
||||
const readFile = vi.fn(async (file: string) => {
|
||||
const entry = files[file]
|
||||
return typeof entry === 'function' ? entry() : entry ?? null
|
||||
})
|
||||
const fetchMock = vi.fn(async (_url: string, _init: RequestInit) => new Response(usage, { status: 200 }))
|
||||
const result = fetchClaudeQuota({
|
||||
fetch: fetchMock as unknown as typeof fetch,
|
||||
credentialPath: WIN,
|
||||
wslCredentialPaths: () => wslPaths,
|
||||
readFile: readFile as never,
|
||||
})
|
||||
const token = async () => {
|
||||
await result
|
||||
return fetchMock.mock.calls.length ? String((fetchMock.mock.calls[0]![1].headers as Record<string, string>).Authorization) : null
|
||||
}
|
||||
return { result, token, readFile }
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
setWslHomes(undefined)
|
||||
})
|
||||
|
||||
describe('Claude quota credential from WSL (#1061)', () => {
|
||||
it('uses the Windows-home credential when no distro is running', async () => {
|
||||
const { result, token } = run({ [WIN]: stored('win', 2) }, [])
|
||||
expect((await result).quota.connection).toBe('connected')
|
||||
expect(await token()).toBe('Bearer win')
|
||||
})
|
||||
|
||||
it('uses a WSL-only credential when the Windows home has none', async () => {
|
||||
const { result, token } = run({ [UBUNTU_CRED]: stored('wsl', 2) }, [UBUNTU_CRED])
|
||||
expect((await result).quota.connection).toBe('connected')
|
||||
expect(await token()).toBe('Bearer wsl')
|
||||
})
|
||||
|
||||
it('picks the credential that expires last when both exist', async () => {
|
||||
const newer = run({ [WIN]: stored('win', 1_000), [UBUNTU_CRED]: stored('wsl', 2_000) }, [UBUNTU_CRED])
|
||||
expect(await newer.token()).toBe('Bearer wsl')
|
||||
const older = run({ [WIN]: stored('win', 3_000), [UBUNTU_CRED]: stored('wsl', 2_000) }, [UBUNTU_CRED])
|
||||
expect(await older.token()).toBe('Bearer win')
|
||||
})
|
||||
|
||||
it('only reads homes of distros the running-only discovery returned', async () => {
|
||||
setWslHomes([UBUNTU])
|
||||
const readFile = vi.fn(async () => null)
|
||||
await fetchClaudeQuota({ fetch: vi.fn() as unknown as typeof fetch, credentialPath: WIN, readFile })
|
||||
expect(readFile.mock.calls.map(call => call[0])).toEqual([WIN, UBUNTU_CRED])
|
||||
})
|
||||
|
||||
it('gives up on an unreachable share instead of hanging the poll', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { result, token } = run({ [WIN]: stored('win', 1), [DEBIAN_CRED]: () => new Promise(() => {}) }, [DEBIAN_CRED])
|
||||
await vi.advanceTimersByTimeAsync(2_500)
|
||||
expect((await result).quota.connection).toBe('connected')
|
||||
expect(await token()).toBe('Bearer win')
|
||||
})
|
||||
|
||||
it('skips a malformed WSL file and still uses a good one', async () => {
|
||||
const { result, token } = run({ [UBUNTU_CRED]: '{not json', [DEBIAN_CRED]: stored('debian', 5) }, [UBUNTU_CRED, DEBIAN_CRED])
|
||||
expect((await result).quota.connection).toBe('connected')
|
||||
expect(await token()).toBe('Bearer debian')
|
||||
})
|
||||
|
||||
it('reports disconnected when the only credential is a malformed WSL file', async () => {
|
||||
const { result, token } = run({ [UBUNTU_CRED]: '{not json' }, [UBUNTU_CRED])
|
||||
expect((await result).quota.connection).toBe('disconnected')
|
||||
expect(await token()).toBeNull()
|
||||
})
|
||||
})
|
||||
|
|
@ -4,7 +4,7 @@
|
|||
//! snapshot file so a freshly reset window can still show last cycle's final.
|
||||
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
|
|
@ -147,6 +147,7 @@ impl PlanClient {
|
|||
|
||||
struct StoredCredentials {
|
||||
access_token: String,
|
||||
expires_at: Option<f64>,
|
||||
rate_limit_tier: Option<String>,
|
||||
subscription_type: Option<String>,
|
||||
}
|
||||
|
|
@ -161,6 +162,8 @@ struct CredentialsRoot {
|
|||
struct OAuthBlock {
|
||||
#[serde(rename = "accessToken")]
|
||||
access_token: Option<String>,
|
||||
#[serde(rename = "expiresAt")]
|
||||
expires_at: Option<serde_json::Value>,
|
||||
#[serde(rename = "rateLimitTier")]
|
||||
rate_limit_tier: Option<String>,
|
||||
#[serde(rename = "subscriptionType")]
|
||||
|
|
@ -171,12 +174,41 @@ fn credentials_path() -> Option<PathBuf> {
|
|||
dirs::home_dir().map(|h| h.join(CREDENTIALS_RELATIVE_PATH))
|
||||
}
|
||||
|
||||
/// Ok(None) when the file does not exist (user never logged in); Err for malformed data.
|
||||
/// The Windows-home credential, or one from a running WSL distro when Claude Code lives
|
||||
/// there (#1061): whichever expires last, since that is the one most recently renewed.
|
||||
fn load_credentials() -> Result<Option<StoredCredentials>> {
|
||||
let Some(path) = credentials_path() else {
|
||||
return Ok(None);
|
||||
let native = match credentials_path() {
|
||||
Some(path) => read_credentials(&path),
|
||||
None => Ok(None),
|
||||
};
|
||||
let meta = match fs::symlink_metadata(&path) {
|
||||
freshest(native, wsl_credentials())
|
||||
}
|
||||
|
||||
/// A malformed Windows file only surfaces as an error when nothing else is usable.
|
||||
fn freshest(
|
||||
native: Result<Option<StoredCredentials>>,
|
||||
wsl: Vec<StoredCredentials>,
|
||||
) -> Result<Option<StoredCredentials>> {
|
||||
let (native, error) = match native {
|
||||
Ok(found) => (found, None),
|
||||
Err(err) => (None, Some(err)),
|
||||
};
|
||||
let best = native.into_iter().chain(wsl).reduce(|best, next| {
|
||||
if next.expires_at.unwrap_or(f64::MIN) > best.expires_at.unwrap_or(f64::MIN) {
|
||||
next
|
||||
} else {
|
||||
best
|
||||
}
|
||||
});
|
||||
match (best, error) {
|
||||
(None, Some(err)) => Err(err),
|
||||
(best, _) => Ok(best),
|
||||
}
|
||||
}
|
||||
|
||||
/// Ok(None) when the file does not exist (user never logged in); Err for malformed data.
|
||||
fn read_credentials(path: &Path) -> Result<Option<StoredCredentials>> {
|
||||
let meta = match fs::symlink_metadata(path) {
|
||||
Ok(m) => m,
|
||||
Err(_) => return Ok(None),
|
||||
};
|
||||
|
|
@ -186,7 +218,7 @@ fn load_credentials() -> Result<Option<StoredCredentials>> {
|
|||
if meta.len() > MAX_CREDENTIAL_BYTES {
|
||||
bail!("credentials file is unexpectedly large");
|
||||
}
|
||||
let bytes = fs::read(&path).with_context(|| "failed to read Claude credentials")?;
|
||||
let bytes = fs::read(path).with_context(|| "failed to read Claude credentials")?;
|
||||
let root: CredentialsRoot =
|
||||
serde_json::from_slice(&bytes).with_context(|| "Claude credentials are malformed")?;
|
||||
let Some(oauth) = root.claude_ai_oauth else {
|
||||
|
|
@ -198,11 +230,112 @@ fn load_credentials() -> Result<Option<StoredCredentials>> {
|
|||
}
|
||||
Ok(Some(StoredCredentials {
|
||||
access_token: token,
|
||||
expires_at: oauth.expires_at.as_ref().and_then(serde_json::Value::as_f64),
|
||||
rate_limit_tier: oauth.rate_limit_tier,
|
||||
subscription_type: oauth.subscription_type,
|
||||
}))
|
||||
}
|
||||
|
||||
// ---- WSL (#1061) ------------------------------------------------------------------------
|
||||
// Mirrors src/wsl.ts: running distros only (touching a stopped distro's share boots it),
|
||||
// `CODEBURN_WSL=off|all` honoured, read-only.
|
||||
|
||||
#[cfg(windows)]
|
||||
const WSL_SCAN_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
/// The scan runs on its own thread so a wedged wsl.exe or 9P share can never stall the
|
||||
/// Plan poll; a scan still stuck from an earlier poll suppresses new ones until it ends.
|
||||
#[cfg(windows)]
|
||||
fn wsl_credentials() -> Vec<StoredCredentials> {
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
static IN_FLIGHT: AtomicBool = AtomicBool::new(false);
|
||||
let mode = std::env::var("CODEBURN_WSL").unwrap_or_default().trim().to_lowercase();
|
||||
if mode == "off" || IN_FLIGHT.swap(true, Ordering::AcqRel) {
|
||||
return Vec::new();
|
||||
}
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
let _ = tx.send(scan_wsl_credentials(mode == "all"));
|
||||
IN_FLIGHT.store(false, Ordering::Release);
|
||||
});
|
||||
rx.recv_timeout(WSL_SCAN_TIMEOUT).unwrap_or_default()
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn wsl_credentials() -> Vec<StoredCredentials> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn scan_wsl_credentials(all: bool) -> Vec<StoredCredentials> {
|
||||
let mut cmd = crate::cli::system_command("wsl.exe");
|
||||
cmd.args(["--list", "--quiet"]);
|
||||
if !all {
|
||||
cmd.arg("--running");
|
||||
}
|
||||
cmd.stdin(std::process::Stdio::null()).stderr(std::process::Stdio::null());
|
||||
let Ok(out) = cmd.output() else { return Vec::new() };
|
||||
if !out.status.success() {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut found = Vec::new();
|
||||
for distro in parse_wsl_distros(&out.stdout) {
|
||||
// `\\wsl$\` first: `wsl.localhost` is missing on older builds, where it stalls in DNS.
|
||||
for prefix in [r"\\wsl$\", r"\\wsl.localhost\"] {
|
||||
let homes = wsl_homes(&PathBuf::from(format!("{prefix}{distro}")));
|
||||
if homes.is_empty() {
|
||||
continue;
|
||||
}
|
||||
for home in homes {
|
||||
if let Ok(Some(creds)) = read_credentials(&home.join(CREDENTIALS_RELATIVE_PATH)) {
|
||||
found.push(creds);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn wsl_homes(base: &Path) -> Vec<PathBuf> {
|
||||
let mut homes: Vec<PathBuf> = fs::read_dir(base.join("home"))
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false))
|
||||
.map(|e| e.path())
|
||||
.collect();
|
||||
let root = base.join("root");
|
||||
if root.is_dir() {
|
||||
homes.push(root);
|
||||
}
|
||||
homes
|
||||
}
|
||||
|
||||
/// `wsl.exe --list --quiet` writes UTF-16LE (BOM optional) with CRLF; with nothing
|
||||
/// installed it prints prose instead, which the single-token name rule filters out.
|
||||
#[cfg_attr(not(windows), allow(dead_code))]
|
||||
fn parse_wsl_distros(raw: &[u8]) -> Vec<String> {
|
||||
let text = if raw.contains(&0) {
|
||||
let units: Vec<u16> = raw.chunks_exact(2).map(|c| u16::from_le_bytes([c[0], c[1]])).collect();
|
||||
String::from_utf16_lossy(&units)
|
||||
} else {
|
||||
String::from_utf8_lossy(raw).into_owned()
|
||||
};
|
||||
text.lines()
|
||||
.map(|line| line.replace(['\0', '\u{feff}'], "").trim().to_string())
|
||||
.filter(|name| {
|
||||
!name.is_empty()
|
||||
&& !name.ends_with('.')
|
||||
&& !name.chars().any(|c| c.is_whitespace() || "\\/:*?\"<>|".contains(c))
|
||||
&& !["docker-desktop", "podman-machine", "rancher-desktop"]
|
||||
.iter()
|
||||
.any(|utility| name.starts_with(utility))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn tier_display(subscription_type: Option<&str>, rate_limit_tier: Option<&str>) -> String {
|
||||
let subscription_type = subscription_type.map(|s| s.to_lowercase()).unwrap_or_default();
|
||||
let tier = rate_limit_tier.map(|r| r.to_lowercase()).unwrap_or_default();
|
||||
|
|
@ -479,7 +612,63 @@ fn civil_from_unix(secs: i64) -> (i64, i64, i64, i64, i64, i64) {
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::tier_display;
|
||||
use super::{freshest, parse_wsl_distros, read_credentials, tier_display, StoredCredentials};
|
||||
|
||||
fn creds(token: &str, expires_at: Option<f64>) -> StoredCredentials {
|
||||
StoredCredentials {
|
||||
access_token: token.into(),
|
||||
expires_at,
|
||||
rate_limit_tier: None,
|
||||
subscription_type: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn token(result: anyhow::Result<Option<StoredCredentials>>) -> Option<String> {
|
||||
result.unwrap().map(|c| c.access_token)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn freshest_credential_wins_across_windows_and_wsl() {
|
||||
assert_eq!(token(freshest(Ok(Some(creds("win", Some(1.0)))), vec![])), Some("win".into()));
|
||||
assert_eq!(token(freshest(Ok(None), vec![creds("wsl", Some(1.0))])), Some("wsl".into()));
|
||||
let both = |win: f64| freshest(Ok(Some(creds("win", Some(win)))), vec![creds("wsl", Some(2.0))]);
|
||||
assert_eq!(token(both(1.0)), Some("wsl".into()));
|
||||
assert_eq!(token(both(3.0)), Some("win".into()));
|
||||
assert_eq!(token(freshest(Ok(None), vec![])), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_windows_file_errors_only_without_a_wsl_fallback() {
|
||||
let broken = || Err(anyhow::anyhow!("Claude credentials are malformed"));
|
||||
assert!(freshest(broken(), vec![]).is_err());
|
||||
assert_eq!(token(freshest(broken(), vec![creds("wsl", None)])), Some("wsl".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_expires_at_and_rejects_malformed_json() {
|
||||
let dir = std::env::temp_dir().join(format!("codeburn-plan-test-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let good = dir.join("good.json");
|
||||
std::fs::write(&good, r#"{"claudeAiOauth":{"accessToken":"t","expiresAt":1760000000000}}"#).unwrap();
|
||||
let bad = dir.join("bad.json");
|
||||
std::fs::write(&bad, "{not json").unwrap();
|
||||
let read = read_credentials(&good).unwrap().unwrap();
|
||||
assert_eq!(read.expires_at, Some(1_760_000_000_000.0));
|
||||
assert!(read_credentials(&bad).is_err());
|
||||
assert!(read_credentials(&dir.join("missing.json")).unwrap().is_none());
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_wsl_list_output() {
|
||||
let utf16: Vec<u8> = "\u{feff}Ubuntu\r\ndocker-desktop\r\nDebian\r\n"
|
||||
.encode_utf16()
|
||||
.flat_map(u16::to_le_bytes)
|
||||
.collect();
|
||||
assert_eq!(parse_wsl_distros(&utf16), vec!["Ubuntu", "Debian"]);
|
||||
let prose = b"Windows Subsystem for Linux has no installed distributions.\r\n";
|
||||
assert!(parse_wsl_distros(prose).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tier_display_prefers_subscription_type() {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue